音乐模块调整
This commit is contained in:
+10
-3
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
+305
-66
@@ -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),
|
||||
|
||||
+221
-71
@@ -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<bool>,
|
||||
) -> Result<MusicEnvStatus, String> {
|
||||
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<Vec<String>>,
|
||||
proxy_url: Option<String>,
|
||||
qq_cookie: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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<Vec<String>>,
|
||||
proxy_url: Option<String>,
|
||||
qq_cookie: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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<serde_json::Value>,
|
||||
songs: Option<Vec<serde_json::Value>>,
|
||||
quality: Option<String>,
|
||||
proxy_url: Option<String>,
|
||||
qq_cookie: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let mut list: Vec<serde_json::Value> = 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<String>,
|
||||
max_concurrent: Option<u32>,
|
||||
quality: Option<String>,
|
||||
qq_cookie: Option<String>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
let s = state.load_settings();
|
||||
@@ -183,10 +248,11 @@ pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_j
|
||||
"kind": c.kind,
|
||||
"baseUrl": c.base_url,
|
||||
"username": c.username,
|
||||
"loggedIn": !c.token.is_empty(),
|
||||
"loggedIn": !c.token.is_empty() || crate::music::secrets::has_feiniu_token(&c.id),
|
||||
"accessCode": c.access_code,
|
||||
"insecure": c.insecure,
|
||||
"fnId": c.fn_id,
|
||||
"relay": c.relay,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -205,14 +271,18 @@ pub fn feiniu_save_connection(
|
||||
if conn.id.is_empty() {
|
||||
conn.id = new_conn_id();
|
||||
}
|
||||
let id = conn.id.clone();
|
||||
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == conn.id) {
|
||||
conn.token = existing.token.clone(); // 保留既有 token
|
||||
// relay 是解析结果而非用户输入:同为 fnconnect 时沿用,切换类型则重置
|
||||
conn.relay = conn.kind == "fnconnect" && existing.kind == "fnconnect" && existing.relay;
|
||||
*existing = conn;
|
||||
} else {
|
||||
settings.feiniu_connections.push(conn);
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
Ok(json!({ "ok": true }))
|
||||
// 回传 id:新建时前端无需按「地址 + 名称」反查,避免 FnConnect(地址为空)匹配失败
|
||||
Ok(json!({ "ok": true, "id": id }))
|
||||
}
|
||||
|
||||
/// 删除一条连接;若删的是激活连接,自动切换激活到第一条。
|
||||
@@ -230,7 +300,16 @@ pub fn feiniu_delete_connection(
|
||||
.map(|c| c.id.clone())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
// 旧版单连接字段是迁移逻辑的输入:残留会让「列表为空」再次被迁移出一条连接。
|
||||
// 删除是明确意图,顺手清掉,保证删了就是删了。
|
||||
settings.feiniu_base_url.clear();
|
||||
settings.feiniu_token.clear();
|
||||
settings.feiniu_username.clear();
|
||||
settings.feiniu_device_id.clear();
|
||||
settings.feiniu_access_code.clear();
|
||||
state.save_settings(&settings)?;
|
||||
// 顺带清掉该连接在系统凭据管理器里的 token,避免留下孤儿凭据
|
||||
let _ = crate::music::secrets::secret_delete(&crate::music::secrets::feiniu_token_key(&id));
|
||||
state.feiniu.sync_with_settings(&settings);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
@@ -270,10 +349,12 @@ pub async fn feiniu_login(
|
||||
// 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?;
|
||||
let (url, relay) = resolve_base_url(&fid).await?;
|
||||
conn.base_url = url;
|
||||
conn.relay = relay;
|
||||
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
c.base_url = conn.base_url.clone();
|
||||
c.relay = relay;
|
||||
}
|
||||
state.save_settings(&settings)?;
|
||||
}
|
||||
@@ -284,8 +365,12 @@ pub async fn feiniu_login(
|
||||
|
||||
let (token, device_id) = state.feiniu.login(&conn.base_url, &username, &password).await?;
|
||||
|
||||
// token 存进系统凭据管理器;只有写入失败才降级为明文落 settings.json(并记日志)。
|
||||
// 顺序很重要:先确认凭据库写成功,再决定要不要把明文留在结构体里。
|
||||
let token_key = crate::music::secrets::feiniu_token_key(&connection_id);
|
||||
let token_protected = crate::music::secrets::try_store(&token_key, &token);
|
||||
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||
existing.token = token.clone();
|
||||
existing.token = if token_protected { String::new() } else { token.clone() };
|
||||
existing.device_id = device_id;
|
||||
existing.username = username;
|
||||
}
|
||||
@@ -293,16 +378,23 @@ pub async fn feiniu_login(
|
||||
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 回传前端:前端除了 mediaPrefix 之外不需要它,少一处明文暴露面
|
||||
Ok(json!({ "ok": true, "mediaPrefix": prefix, "protected": token_protected }))
|
||||
}
|
||||
|
||||
/// 登出某连接(清 token,保留地址/账号),代理 Cookie 同步失效。
|
||||
///
|
||||
/// 顺序有讲究:**必须先删凭据库里的 token 再 sync**,
|
||||
/// 否则 `sync_with_settings` 会从凭据库把刚登出的 token 又读回运行期(看起来「登出无效」)。
|
||||
#[tauri::command]
|
||||
pub fn feiniu_logout(
|
||||
state: State<'_, MusicManager>,
|
||||
connection_id: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
/// 允许访问的本地目录(下载目录 + 自定义曲库目录)。
|
||||
/// 本地文件的**列举与删除**都限定在其中——前端只应能操作曲库范围内的文件。
|
||||
fn allowed_local_roots(state: &MusicManager) -> Vec<String> {
|
||||
let s = state.load_settings();
|
||||
let mut dirs: Vec<String> = vec![s.savedir.clone()];
|
||||
for d in &s.feiniu_local_dirs {
|
||||
@@ -400,7 +505,55 @@ pub fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result<serde_json::V
|
||||
dirs.push(d.clone());
|
||||
}
|
||||
}
|
||||
Ok(state.feiniu.scan_local(&dirs))
|
||||
dirs.into_iter().filter(|d| !d.trim().is_empty()).collect()
|
||||
}
|
||||
|
||||
/// 路径是否位于允许的根目录之下。
|
||||
/// 两侧都先 `canonicalize`:`..` 与符号链接因此无法越出根目录。
|
||||
fn is_within_roots(path: &std::path::Path, roots: &[String]) -> 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<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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, String> {
|
||||
keyring::Entry::new(WEBDAV_SECRET_SERVICE, WEBDAV_SECRET_USER)
|
||||
.map_err(|e| format!("无法访问系统凭据管理器: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn webdav_secret_read() -> Result<Option<serde_json::Value>, 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<serde_json::Value, String> {
|
||||
#[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::<serde_json::Value>(&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<serde_json::Value, String> {
|
||||
#[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<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
pub fn feiniu_delete_local(
|
||||
state: State<'_, MusicManager>,
|
||||
path: String,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
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<serde_json::Value, String> {
|
||||
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 }))
|
||||
}
|
||||
|
||||
@@ -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<String, CacheEntry> {
|
||||
fs::read_to_string(&self.index_path)
|
||||
.ok()
|
||||
|
||||
@@ -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. 中继地址(`<fnId>.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/<id>`、`<id>.5ddd.com`、或裸 fnId。
|
||||
/// 从输入识别 fnId:`fnos.net/<id>`、`5ddd.com/<id>`、`<id>.fnos.net`、`<id>.5ddd.com`、或裸 fnId。
|
||||
pub fn extract_fn_id(input: &str) -> Option<String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(id) = s.split_once("fnos.net/").map(|(_, r)| r.split('/').next().unwrap_or("")) {
|
||||
if !id.is_empty() {
|
||||
return Some(id.trim().to_string());
|
||||
// 去协议后统一按「可能带路径的 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('/');
|
||||
// 形如 <网关>/<id>
|
||||
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());
|
||||
// 形如 <id>.<网关>
|
||||
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<Value, String> {
|
||||
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<String> = if relays.is_empty() {
|
||||
vec!["5ddd.com".to_string()]
|
||||
} else {
|
||||
relays
|
||||
let empty: Vec<Value> = Vec::new();
|
||||
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
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<usize> = (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())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> 或裸 id)
|
||||
#[serde(default)]
|
||||
pub fn_id: String,
|
||||
/// 是否经由 FnConnect 中继链路(`<fnId>.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<Option<ClientSlot>>,
|
||||
/// 流代理专用 client(无总超时)
|
||||
stream_client: Mutex<Option<ClientSlot>>,
|
||||
conn: Mutex<Conn>,
|
||||
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
||||
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<Option<ClientSlot>>, 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<String> {
|
||||
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<Value> = Vec::new();
|
||||
for dir in dirs {
|
||||
let p = Path::new(dir);
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
for entry in walkdir::WalkDir::new(p).follow_links(false) {
|
||||
let Ok(entry) = entry else { continue };
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
if !EXTS.contains(&ext.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
let mtim = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("未知")
|
||||
.to_string();
|
||||
items.push(json!({
|
||||
"path": path.to_string_lossy(),
|
||||
"title": name,
|
||||
"size": size,
|
||||
"mtim": mtim,
|
||||
"dir": dir,
|
||||
}));
|
||||
}
|
||||
}
|
||||
json!({ "items": items })
|
||||
}
|
||||
|
||||
/// 缓存状态。
|
||||
pub fn cache_status(&self) -> Value {
|
||||
@@ -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<Value, String> {
|
||||
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<Value> = 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<String, Value> = 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<String, Value>,
|
||||
path: &str,
|
||||
size: u64,
|
||||
mtim: u64|
|
||||
-> Option<Value> {
|
||||
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<Value> = Vec::new();
|
||||
// 本次扫描命中的音频路径:用于修剪缓存(见函数末尾)
|
||||
let mut seen: std::collections::HashSet<String> = 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 {
|
||||
|
||||
@@ -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<u16, String> {
|
||||
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<u16, String> {
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
/// 本地音频文件的内嵌封面:`GET /feiniu/local-cover?path=<绝对路径>`。
|
||||
///
|
||||
/// 供「本地曲库」列表显示封面(无需 NAS 登录)。只接受音频扩展名,且必须
|
||||
/// 能被 lofty 解析出内嵌图片——非音频文件在这里必然 404,不构成任意文件读取。
|
||||
/// 响应带 immutable 缓存头:path 不变时 WebView 直接复用。
|
||||
async fn proxy_local_cover(Query(q): Query<HashMap<String, String>>) -> 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<MimeType>
|
||||
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<ProxyShared>,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
@@ -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
|
||||
}
|
||||
+114
-26
@@ -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<String>,
|
||||
/// 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准
|
||||
pub musicdl_expected: String,
|
||||
/// 已装 musicdl 是否与锁定版本不一致。
|
||||
/// 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。
|
||||
pub musicdl_outdated: bool,
|
||||
/// FFmpeg 是否可用(部分音源需要,非必需)
|
||||
pub ffmpeg: Option<String>,
|
||||
/// 桥接进程是否在运行
|
||||
@@ -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<FeiniuConnection>,
|
||||
/// 当前激活连接的 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<String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 已装版本是否就是本应用锁定的版本。
|
||||
///
|
||||
/// `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<String>) {
|
||||
let mut cmd = std::process::Command::new(exe);
|
||||
|
||||
@@ -158,6 +158,86 @@ impl MusicManager {
|
||||
ok
|
||||
}
|
||||
|
||||
/// 把已装的 musicdl 对齐到本应用锁定的版本(`MUSICDL_VERSION`)。
|
||||
///
|
||||
/// 为什么必须有这个动作:`install_runtime_inner` 的闸门是「能否 import」而不是
|
||||
/// 「版本是否一致」——仅升级应用(哪怕代码里的锁定版本提高了)**不会**触发 pip,
|
||||
/// 已装环境会永远停在旧版本。这里显式执行 `pip install --upgrade musicdl==<pinned>`;
|
||||
/// `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<String> = 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,
|
||||
|
||||
@@ -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<Option<String>, 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<Option<String>, 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()
|
||||
}
|
||||
Reference in New Issue
Block a user