音乐模块调整

This commit is contained in:
zhongluofeng
2026-09-15 17:17:16 +08:00
parent 4f574cb5fa
commit 8f853f7ef3
31 changed files with 3819 additions and 936 deletions
+11 -10
View File
@@ -54,17 +54,18 @@ $tauriConf = Join-Path $Root 'src-tauri\tauri.conf.json'
$cargoToml = Join-Path $Root 'src-tauri\Cargo.toml'
$pkgJson = Join-Path $Root 'package.json'
$t = Get-Content $tauriConf -Raw
$t = $t -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}"
[System.IO.File]::WriteAllText($tauriConf, $t, (New-Object System.Text.UTF8Encoding($false)))
# UTF-8 安全读写:`Get-Content` 默认按系统 ANSI(如 GBK) 解码,会把本就含中文/乱码的文件二次编码损坏(曾导致
# Cargo.toml 的 keyring 依赖行被弄丢)。这里统一用显式 UTF-8 无 BOM 读写,保证逐字节稳定往返。
function Set-Utf8Version([string]$Path, [string]$Pattern, [string]$NewVersion) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
$content = [System.IO.File]::ReadAllText($Path, $utf8NoBom)
$content = $content -replace $Pattern, "`${1}$NewVersion`${2}"
[System.IO.File]::WriteAllText($Path, $content, $utf8NoBom)
}
$c = Get-Content $cargoToml -Raw
$c = $c -replace '(?m)^(version\s*=\s*")[^"]*(")', "`${1}$Version`${2}"
[System.IO.File]::WriteAllText($cargoToml, $c, (New-Object System.Text.UTF8Encoding($false)))
$p = Get-Content $pkgJson -Raw
$p = $p -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}"
[System.IO.File]::WriteAllText($pkgJson, $p, (New-Object System.Text.UTF8Encoding($false)))
Set-Utf8Version -Path $tauriConf -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version
Set-Utf8Version -Path $cargoToml -Pattern '(?m)^(version\s*=\s*")[^"]*(")' -NewVersion $Version
Set-Utf8Version -Path $pkgJson -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version
Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json"
# ---------- 2. 构建 ----------
+36
View File
@@ -3318,6 +3318,32 @@ dependencies = [
"scopeguard",
]
[[package]]
name = "lofty"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca260c51a9c71f823fbfd2e6fbc8eb2ee09834b98c00763d877ca8bfa85cde3e"
dependencies = [
"byteorder",
"data-encoding",
"flate2",
"lofty_attr",
"log",
"ogg_pager",
"paste",
]
[[package]]
name = "lofty_attr"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed9983e64b2358522f745c1251924e3ab7252d55637e80f6a0a3de642d6a9efc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "log"
version = "0.4.33"
@@ -3934,6 +3960,15 @@ dependencies = [
"objc2-foundation",
]
[[package]]
name = "ogg_pager"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d36b1d6964c3ac92b7aea701057e02b6b91143d70d83b20abf75a231a3c0216"
dependencies = [
"byteorder",
]
[[package]]
name = "once_cell"
version = "1.21.4"
@@ -6163,6 +6198,7 @@ dependencies = [
"image",
"keyring",
"librqbit",
"lofty",
"md-5",
"notify",
"rand 0.8.8",
+2
View File
@@ -39,6 +39,8 @@ axum = "0.7"
url = "2"
zip = "2"
dirs = "5"
# 音频标签解析(本地曲库元数据:标题/歌手/专辑/时长/内嵌封面)
lofty = "0.22"
sysinfo = "0.32"
rusqlite = { version = "0.32", features = ["bundled"] }
base64 = "0.22"
+10 -3
View File
@@ -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,
+283 -44
View File
@@ -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
# QQSongFileType.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
client = MusicClient(
music_sources=[source],
init_music_clients_cfg={
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=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,7 +1014,9 @@ def handle_search(params):
if not keyword:
raise ValueError("keyword 不能为空")
sources = params.get("sources") or DEFAULT_SOURCES
client = get_client(sources)
# 搜索全程按模块设置决定是否走代理(缺省强制直连,避免被系统代理带走)
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):
@@ -954,12 +1051,154 @@ 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)
# 与搜索一致:按模块设置决定是否走代理(缺省强制直连)
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 无元数据),
# 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接)
@@ -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,6 +1246,9 @@ def handle_resolve(params):
return song_to_dict(resolved)
with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool:
# 解析同样要联网:按模块设置决定是否走代理(缺省强制直连)。
# 工作线程读取的是同一个 _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 音乐 CookieVIP 音质 / 官方 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 字节写入 stdinserde_json 序列化不转义非 ASCII),
+221 -71
View File
@@ -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)。
/// 标签解析结果缓存在 CacheManagersize+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::secretsWebDAV 账号密码、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 }))
}
#[cfg(not(windows))]
{
let _ = (&username, &password);
/// 校验前端可访问的凭据键:只允许白名单内的键(见 `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}"))
}
}
/// 读取一个音乐模块的敏感串(如 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 }))
}
+7
View File
@@ -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()
+202 -61
View File
@@ -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("")) {
// 去协议后统一按「可能带路径的 host」处理
let mut t = s;
for p in ["https://", "http://"] {
if let Some(r) = t.strip_prefix(p) {
t = r;
break;
}
}
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.trim().to_string());
return Some(id.to_string());
}
}
if let Some(rest) = s.rsplit_once("/") {
let last = rest.1;
if last.ends_with(".5ddd.com") {
return Some(last.trim_end_matches(".5ddd.com").to_string());
}
// 形如 <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());
}
}
if s.ends_with(".5ddd.com") {
return Some(s.trim_end_matches(".5ddd.com").to_string());
}
// 裸 fnId
if s.len() >= 3 && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_') && !s.starts_with("http") {
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}&timestamp={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)
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", fn_authx("post", "/api/v1/fn/con", &body))
.header("authx", authx.clone())
.json(&body)
.send()
.await
.map_err(|e| format!("FnConnect 网关不可达: {e}"))?;
let b: Value = resp.json().await.map_err(|e| e.to_string())?;
if b["code"].as_i64().unwrap_or(-1) != 0 {
return Err(b["msg"].as_str().unwrap_or("FnConnect 网关返回错误").to_string());
{
Ok(r) => r,
Err(e) => {
last_err = format!("FnConnect 网关不可达: {e}");
continue;
}
Ok(b["data"].clone())
};
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());
}
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()) {
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())
.collect()
};
// 1) 内网 IPv4
for ip in strings("ipv4") {
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()) {
// 2) 公网 IPv6NAS 侧可禁用)
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));
}
let relays = data["fn"].as_array().unwrap_or(&empty);
let relay_addrs: Vec<String> = if relays.is_empty() {
vec!["5ddd.com".to_string()]
} else {
relays
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
};
for addr in relay_addrs {
let domain = addr.split(':').next().unwrap_or(&addr).to_string();
}
// 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())),
}
}
+303 -75
View File
@@ -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 {
}
}
impl Feiniu {
/// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。
pub fn set_cache_root(&mut self, app_data_dir: &Path) {
self.cache = CacheManager::new(app_data_dir);
}
/// 取(并惰性构建)对应 insecure 的 reqwest client。
fn client(&self, insecure: bool) -> reqwest::Client {
let mut g = self.client.lock().unwrap_or_else(|e| e.into_inner());
/// 按 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: reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(10))
.danger_accept_invalid_certs(insecure)
.build()
.unwrap_or_else(|_| reqwest::Client::new()),
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);
}
/// 本地曲库标签缓存文件路径({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 {
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 {
+74 -1
View File
@@ -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
}
+100 -12
View File
@@ -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,10 +189,17 @@ 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() {
/// 兼容旧版单连接字段:把旧字段迁移成列表里的一条连接,并**清除旧字段**
///
/// 必须是「一次性」的:旧字段一旦残留,用户把连接删光后,下一次 `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(),
@@ -191,16 +212,61 @@ impl MusicSettings {
access_code: self.feiniu_access_code.clone(),
insecure: false,
fn_id: String::new(),
relay: false,
});
self.feiniu_active_id = "default".to_string();
changed = true;
}
} else if self.feiniu_active_id.is_empty()
// 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;
}
}
// 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);
+80
View File
@@ -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,
+124
View File
@@ -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()
}
+4 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Music2, Pause, Play, Plus, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Trash2, X } from '@lucide/vue'
import { ListMusic, Music2, Pause, Play, Repeat, Repeat1, Shuffle, SkipBack, SkipForward, Trash2, X } from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import ScrubBar from '@/components/common/ScrubBar.vue'
import SegmentedNav from '@/components/common/SegmentedNav.vue'
@@ -210,10 +210,11 @@ function toggleAt(index: number) {
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
:class="{ 'text-foreground': store.nowPlayingTab === 'queue' }"
aria-label="播放队列"
:aria-label="`播放队列(${store.queue.length}`"
:title="`播放队列(${store.queue.length}`"
@click="store.nowPlayingTab = 'queue'"
>
<Plus class="size-4" />
<ListMusic class="size-4" />
</button>
</div>
</div>
+157 -17
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import {
Disc3,
@@ -17,13 +17,116 @@ import {
VolumeX
} from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { createLogger } from '@/lib/logger'
import ScrubBar from '@/components/common/ScrubBar.vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
const store = useFeiniuStore()
const logger = createLogger('music-widget')
const open = ref(false)
/** 悬停多久后打开播放控制窗 */
const HOVER_OPEN_DELAY = 300
/** 弹层是否由悬停打开:据此决定鼠标移出时是否自动关闭(点击打开则不自动关) */
let hoverOpened = false
let hoverTimer: ReturnType<typeof setTimeout> | null = null
let closeTimer: ReturnType<typeof setTimeout> | null = null
/** 整库起播进行中(拉列表可能耗时,兼作重复点击保护) */
const starting = ref(false)
function cancelHoverTimer() {
if (hoverTimer) {
clearTimeout(hoverTimer)
hoverTimer = null
}
}
function cancelCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer)
closeTimer = null
}
}
/** 悬停 0.3s 打开控制窗(不依赖 Popover 默认的点击展开) */
function onTriggerEnter() {
cancelCloseTimer()
cancelHoverTimer()
hoverTimer = setTimeout(() => {
hoverTimer = null
if (open.value) return
hoverOpened = true
open.value = true
}, HOVER_OPEN_DELAY)
}
function onTriggerLeave() {
cancelHoverTimer()
// 悬停打开的:给鼠标从按钮移到弹层留一点余量
if (!open.value || !hoverOpened) return
cancelCloseTimer()
closeTimer = setTimeout(() => (open.value = false), 250)
}
function onContentEnter() {
cancelCloseTimer()
}
function onContentLeave() {
if (!hoverOpened) return
cancelCloseTimer()
closeTimer = setTimeout(() => (open.value = false), 180)
}
watch(open, (v) => {
if (v) return
cancelCloseTimer()
cancelHoverTimer()
hoverOpened = false
})
/** 点击标题:打开播放控制窗(点击打开的弹层不随鼠标移出自动关闭) */
function onTitleClick() {
cancelHoverTimer()
cancelCloseTimer()
hoverOpened = false
open.value = true
}
/**
* 点击**音乐图标**:播放 / 暂停。
* - 有播放上下文(队列非空)→ 播放 / 暂停;
* - 完全空载 → 依次尝试飞牛曲库**全部列表** → 本地曲库;两者皆空则不做任何反应。
*/
async function onIconClick() {
if (store.nowPlaying || store.queue.length) {
store.toggle()
return
}
if (starting.value) return
starting.value = true
try {
try {
await store.loadAllTracks()
} catch (e) {
// 未登录 / 网络不通:按「飞牛曲库为空」处理,继续尝试本地
logger.error(`加载飞牛曲库失败: ${e}`)
}
if (store.tracks.length) {
store.playQueue(store.tracks, 0)
return
}
try {
await store.scanLocal()
} catch (e) {
logger.error(`扫描本地曲库失败: ${e}`)
}
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
} finally {
starting.value = false
}
}
const coverUrl = computed(() => {
const t = store.nowPlaying
if (!t) return ''
@@ -84,44 +187,81 @@ watch(
store.clearPlayError()
}
)
// 应用启动即初始化音乐 store(幂等):恢复播放列表/播放进度并预载上次在播的曲目。
// 这样标题栏控件不必等用户先进「音乐库」页,也能显示并续播上次的歌。
onMounted(() => {
store.init().catch((e) => logger.error(`音乐初始化失败: ${e}`))
})
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<!-- 音乐栏唱片图标 + 歌名未播放过时只显示图标 -->
<button
type="button"
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 text-left transition-colors hover:bg-secondary/60"
:title="hasTrack ? `${title}${subtitle ? ' · ' + subtitle : ''}` : '未在播放'"
:aria-label="hasTrack ? `音乐控制${title}` : '音乐控制'"
<PopoverAnchor as-child>
<!-- 音乐栏图标 = 播放/暂停悬停有遮罩标题 = 打开控制窗
悬停 0.3s 也会打开控制窗容器本身不响应点击避免与图标语义冲突 -->
<div
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-secondary/60"
@mouseenter="onTriggerEnter"
@mouseleave="onTriggerLeave"
@mousedown.stop
>
<span
class="disc flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full"
:class="{ 'is-playing': store.playing }"
<!-- 唱片图标点击开始/暂停 -->
<button
type="button"
class="group/icon relative flex size-5 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-full"
:aria-label="store.playing ? '暂停' : '播放'"
@click.stop="onIconClick"
>
<!-- 旋转动画只作用于这一层否则遮罩图标会被带着一起转 -->
<span
class="disc flex size-5 items-center justify-center rounded-full ring-1 ring-border/50"
:class="{ 'is-playing': store.playing && !starting }"
>
<span
v-if="starting"
class="size-3 animate-spin rounded-full border-2 border-current border-t-transparent text-muted-foreground"
/>
<img
v-if="coverUrl && !coverFailed"
v-else-if="coverUrl && !coverFailed"
:src="coverUrl"
class="size-full object-cover"
class="size-full rounded-full object-cover"
alt=""
referrerpolicy="no-referrer"
@error="coverFailed = true"
/>
<Disc3 v-else class="size-4 text-muted-foreground" />
</span>
<span v-if="hasTrack" class="min-w-0 flex-1 truncate text-xs text-foreground/90">
{{ title }}
<!-- 播放态遮罩悬停图标时浮现当前可执行的操作 -->
<span
v-if="!starting"
class="absolute inset-0 hidden items-center justify-center bg-foreground/50 text-primary-foreground group-hover/icon:flex"
>
<Pause v-if="store.playing" class="size-3" />
<Play v-else class="size-3 translate-x-px" />
</span>
</button>
</PopoverTrigger>
<!-- 标题点击打开播放控制窗 -->
<button
v-if="hasTrack"
type="button"
class="min-w-0 flex-1 cursor-pointer truncate text-left text-xs text-foreground/90"
:aria-label="`打开播放控制:${title}`"
@click.stop="onTitleClick"
>
{{ title }}
</button>
</div>
</PopoverAnchor>
<!-- 方形播放控制窗 -->
<PopoverContent
align="end"
:side-offset="8"
class="w-[300px] p-3"
@mouseenter="onContentEnter"
@mouseleave="onContentLeave"
@mousedown.stop
>
<div class="flex flex-col items-center gap-3">
+30 -2
View File
@@ -254,7 +254,18 @@ export const commands = {
musicStopBridge: () => __TAURI_INVOKE<null>("music_stop_bridge"),
/** 读取音乐模块设置 */
musicGetSettings: () => __TAURI_INVOKE<MusicSettings>("music_get_settings"),
/** 保存音乐模块设置(立即生效) */
/**
* 保存音乐模块设置(立即生效)
*
* 飞牛音乐**连接相关字段一律以磁盘为准**,不接受前端传值:
* 连接列表 / 激活连接 / 旧版单连接字段只由 `feiniu_save_connection`、
* `feiniu_activate_connection`、`feiniu_delete_connection`、`feiniu_login`
* 等专用命令维护。
*
* 原因:前端 `musicStore` 只在 init 时读一次整份设置并长期复用快照,
* 若允许它整份回写,删除连接后任意一次设置保存(哪怕是切页触发的)
* 都会把已删除的连接从旧快照里写回来。
*/
musicSaveSettings: (settings: MusicSettings) => __TAURI_INVOKE<null>("music_save_settings", { settings }),
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
@@ -567,6 +578,11 @@ export type FeiniuConnection = {
insecure: boolean,
/** fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id */
fnId?: string,
/**
* 是否经由 FnConnect 中继链路(`<fnId>.fnos.net`)。
* 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。
*/
relay?: boolean,
};
export type FileEntry = {
@@ -623,6 +639,13 @@ export type MusicEnvStatus = {
musicdlInstalled: boolean,
/** musicdl 版本 */
musicdlVersion: string | null,
/** 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准 */
musicdlExpected: string,
/**
* 已装 musicdl 是否与锁定版本不一致。
* 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。
*/
musicdlOutdated: boolean,
/** FFmpeg 是否可用(部分音源需要,非必需) */
ffmpeg: string | null,
/** 桥接进程是否在运行 */
@@ -651,6 +674,11 @@ export type MusicSettings = {
selectQualityOnDownload: boolean,
/** 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K" */
defaultDownloadQuality: string,
/**
* QQ 音乐 Cookie(可选):用于解析需要登录的歌单(含自己的隐私歌单)与 VIP 音质。
* 传给 musicdl 的 default_search/parse/download_cookies;空=游客身份。
*/
qqCookie?: string,
/** 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置) */
feiniuBaseUrl?: string,
/** 飞牛音乐登录 token(登录成功后保存) */
@@ -661,7 +689,7 @@ export type MusicSettings = {
feiniuDeviceId?: string,
/** 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空) */
feiniuAccessCode?: string,
/** 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect */
/** 飞牛音乐连接列表(多连接:局域网 / FnConnect */
feiniuConnections?: FeiniuConnection[],
/** 当前激活连接的 id */
feiniuActiveId?: string,
+9 -5
View File
@@ -29,16 +29,18 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
* ```
*
* ## 工作原理
* 1. onMounted 时注册标签到 moduleTabsStoreTitleBar 据此渲染浮动切换器
* 1. onMounted 时注册标签到 moduleTabsStore(**并复用该模块上次停留的 tab**),
* TitleBar 据此渲染浮动切换器
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
* 5. onUnmounted 时清理 observer 并注销标签
* 5. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
*
* ## 约束
* - TitleBar 高度固定为 40px (h-10)composable 内部已用 44px 裁剪(含缓冲)
* - 一个模块同一时间只能注册一组标签(store 是单例)
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
* - tab 记忆只保内存、不落盘:tab 集合可能随版本变化,跨重启恢复旧值风险更大
*/
export function useModuleTabs(
moduleId: string,
@@ -97,10 +99,12 @@ export function useModuleTabs(
})
onMounted(async () => {
tabsStore.registerTabs(tabs, activeTab.value)
// 注册时带上模块 id:store 据此复用「上次停留的 tab」(模块卸载时保存)
const restored = tabsStore.registerTabs(moduleId, tabs, activeTab.value)
if (restored !== activeTab.value) activeTab.value = restored
await nextTick()
setupObserver()
// 搜索导航跳转:模块刚挂载,消费待跳转 tab
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
applyPendingTab()
})
@@ -109,7 +113,7 @@ export function useModuleTabs(
observer.disconnect()
observer = null
}
tabsStore.unregisterTabs()
tabsStore.unregisterTabs(moduleId)
})
return tabsListRef
File diff suppressed because it is too large Load Diff
+249 -44
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import {
Cloud,
@@ -16,7 +16,7 @@ import {
Search,
Trash2
} from '@lucide/vue'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { useFeiniuStore, type PlayableItem } from '@/stores/feiniuStore'
import TrackItem from './TrackItem.vue'
import PlaylistEditorDialog from './PlaylistEditorDialog.vue'
import { Button } from '@/components/ui/button'
@@ -46,21 +46,96 @@ const sourceNav = computed(() => [
{ value: 'playlists' as const, label: '我的歌单', icon: ListMusic, count: store.playlists.length }
])
// ===== 飞牛曲库搜索(直接绑定 store.searchKeyword,此前绑定的是孤立局部变量,导致搜索完全无效)=====
// ===== 曲库搜索:飞牛 track/list 接口**不支持**关键字过滤(实测忽略该参数),
// 因此搜索框只做**本地筛选**(歌名/歌手/专辑);输入时自动补齐剩余分页,
// 保证筛选覆盖全库而不是已加载的前几页。两个视图(飞牛/本地)共用同一个关键字。
let searchTimer: ReturnType<typeof setTimeout> | undefined
let searchSeq = 0
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
/** 全量补齐进行中:输入防抖与视图切换可能同时触发,用标志位避免并发重复翻页 */
let fillingAll = false
/** 本组件已完成挂载初始化:此前不触发补齐,避免与 ensureTracksFresh 的首页请求并发 */
let mountedReady = false
/**
* 补齐飞牛曲库的剩余分页。
* 飞牛 `track/list` 不支持关键字过滤,搜索框只做**本地筛选**,
* 因此必须先把剩余分页拉完,否则只能在「已加载的前几页」里找,
* 表现为「曲库里明明有这首歌却搜不到」。
*
* 必须避让任何在途的分页请求:补齐是「按页追加」,而首页刷新是
* `tracks = mapped`(整体替换);两者交错会把刚追加的页直接抹掉,造成曲目缺口。
*/
async function fillRemainingFeiniu() {
if (fillingAll || !store.hasMoreTracks) return
if (store.loading || store.refreshing || store.loadingMore) return
fillingAll = true
const seq = ++searchSeq
try {
await store.loadTracks(1)
await store.loadRemainingTracks()
} catch (e) {
if (seq === searchSeq) toast.error(String(e))
} finally {
fillingAll = false
}
}, 400)
}
/**
* 搜索框输入:仅飞牛曲库需要补齐分页(本地曲库是已全量在内存的数组,输入即筛)。
* 两个视图共用同一个关键字,但按各自的数据来源决定是否发请求。
*/
function onSearchInput() {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
if (view.value !== 'feiniu') return
void fillRemainingFeiniu()
}, 300)
}
const isFiltering = computed(() => !!filterKeyword.value)
/** 关键字规范化:全角空格→半角、压缩连续空白、转小写(中文不受影响) */
function normalizeKeyword(s: string) {
return s
.replace(/\u3000/g, ' ')
.trim()
.replace(/\s+/g, ' ')
.toLowerCase()
}
const filterKeyword = computed(() => normalizeKeyword(store.searchKeyword))
/**
* 保证飞牛曲库的筛选覆盖全库。
* 关键字在两个视图之间共享,所以「带着关键字从本地切回飞牛曲库」时
* 也必须补齐分页,否则只会在已加载的前几页里筛,结果数明显偏小。
* 挂载阶段由 onMounted 的串行链负责,此处只在挂载后响应视图 / 关键字变化。
*/
function ensureFeiniuFilterCoverage() {
if (!mountedReady) return
if (view.value !== 'feiniu' || !filterKeyword.value) return
void fillRemainingFeiniu()
}
watch([view, filterKeyword], ensureFeiniuFilterCoverage)
function matchesKeyword(t: PlayableItem, kw: string) {
// 曲库条目(PlayableItem)的字段是 title=歌名 / artistNames=歌手 / album=专辑。
// 千万不要用 musicdl 的 songName/singers——那是「发现音乐」的结构,用错字段
// 会导致 haystack 只剩专辑,表现为"歌名/歌手都搜不到"。
return `${t.title ?? ''} ${t.artistNames ?? ''} ${t.album ?? ''}`.toLowerCase().includes(kw)
}
const filteredTracks = computed(() => {
const kw = filterKeyword.value
if (!kw) return store.tracks
return store.tracks.filter((t) => matchesKeyword(t, kw))
})
const filteredLocalTracks = computed(() => {
const kw = filterKeyword.value
if (!kw) return store.localTracks
return store.localTracks.filter((t) => matchesKeyword(t, kw))
})
function refreshFeiniu() {
store.loadTracks(1).catch((e) => toast.error(String(e)))
}
@@ -69,23 +144,66 @@ function refreshLocal() {
}
function playFeiniu() {
if (store.tracks.length) store.playQueue(store.tracks, 0)
// 有筛选时只播放筛选结果
if (filteredTracks.value.length) store.playQueue(filteredTracks.value, 0)
}
function playLocal() {
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
if (filteredLocalTracks.value.length) store.playQueue(filteredLocalTracks.value, 0)
}
function playActivePlaylist() {
if (activePlaylist.value?.items.length) store.playQueue(activePlaylist.value.items, 0)
}
// ===== 滚动加载更多(飞牛曲库分页)=====
// ===== 列表增量渲染 + 滚动加载更多 =====
/**
* 一次挂载多少行。曲库没有虚拟滚动,补齐分页后行数可达 MAX_QUEUE_TRACKS(4000)
* 一次性挂载 4000 个 TrackItem(每个含封面 <img> 与下拉菜单)会明显卡顿,
* 因此改成「先渲染一屏,滚到底再追加」(与发现音乐页、播放队列同一策略)。
*/
const LIST_STEP = 200
const renderLimit = ref(LIST_STEP)
/** 当前视图正在展示的完整列表 */
const activeList = computed<PlayableItem[]>(() => {
if (view.value === 'feiniu') return filteredTracks.value
if (view.value === 'local') return filteredLocalTracks.value
return activePlaylist.value?.items ?? []
})
const visibleFeiniu = computed(() => filteredTracks.value.slice(0, renderLimit.value))
const visibleLocal = computed(() => filteredLocalTracks.value.slice(0, renderLimit.value))
const visiblePlaylistItems = computed(() => (activePlaylist.value?.items ?? []).slice(0, renderLimit.value))
/** 还有没渲染出来的行 */
const hasMoreRows = computed(() => renderLimit.value < activeList.value.length)
/** 实际渲染出来的行数(提示文案用) */
const shownRows = computed(() => Math.min(renderLimit.value, activeList.value.length))
/** 哨兵是否显示:要么还有行没渲染,要么飞牛曲库还有分页没拉 */
const showSentinel = computed(
() => hasMoreRows.value || (view.value === 'feiniu' && store.hasMoreTracks)
)
// 换视图 / 换关键字 / 换歌单时把渲染窗口收回一屏(监听放在 activePlaylistId 声明之后)
const loadMoreRef = ref<HTMLElement | null>(null)
let loadMoreObserver: IntersectionObserver | null = null
onMounted(() => {
loadMoreObserver = new IntersectionObserver(
(entries) => {
if (!entries.some((e) => e.isIntersecting)) return
store.loadMoreTracks().catch(() => {})
// 先把已加载的数据渲染出来,都渲染完了再向 NAS 要下一页
if (renderLimit.value < activeList.value.length) {
renderLimit.value += LIST_STEP
// 哨兵节点没换位置、仍处于交叉状态时 IntersectionObserver 不会再触发,
// 需要重新 observe 一次才能连续追加
nextTick(() => {
const el = loadMoreRef.value
if (el && loadMoreObserver) {
loadMoreObserver.unobserve(el)
loadMoreObserver.observe(el)
}
})
return
}
if (view.value === 'feiniu' && store.hasMoreTracks) store.loadMoreTracks().catch(() => {})
},
{ rootMargin: '400px' }
)
@@ -118,6 +236,11 @@ const editorOpen = ref(false)
// 切换歌单时关闭编辑器,避免对着已切换的目标继续编辑
watch(activePlaylistId, () => (editorOpen.value = false))
// 换视图 / 换关键字 / 换歌单时把增量渲染窗口收回一屏,避免「切回来看到一屏旧内容」
watch([view, filterKeyword, activePlaylistId], () => {
renderLimit.value = LIST_STEP
})
// 切到歌单视图时自动选中第一个
watch(
[view, () => store.playlists.length],
@@ -159,9 +282,44 @@ function removePlaylist() {
activePlaylistId.value = ''
}
// ===== 视图与筛选持久化:曲库页每次挂载都会重置本地状态,
// 而"搜索结果每次都要重新输"是用户明确反馈的问题 → 存 localStorage(跨重启) =====
const LIB_VIEW_KEY = 'thing.music.library.view'
const LIB_FILTER_KEY = 'thing.music.library.filter'
watch(
() => [view.value, store.searchKeyword] as const,
([v, kw]) => {
try {
localStorage.setItem(LIB_VIEW_KEY, v)
localStorage.setItem(LIB_FILTER_KEY, kw ?? '')
} catch {
/* 存储不可用时静默 */
}
}
)
onMounted(async () => {
try {
// 恢复上次的视图与筛选关键字
const savedView = localStorage.getItem(LIB_VIEW_KEY)
if (savedView === 'feiniu' || savedView === 'local' || savedView === 'playlists') {
view.value = savedView
}
store.searchKeyword = localStorage.getItem(LIB_FILTER_KEY) ?? ''
await store.init()
if (store.config.loggedIn) store.loadTracks(1).catch(() => {})
// 进入曲库页:命中新鲜窗口直接用内存缓存(不请求),过期才后台静默刷新,
// 避免每次切页都清空列表 + 重新拉取 + 重下封面
if (store.config.loggedIn) await store.ensureTracksFresh().catch(() => {})
// 恢复的是本地视图时自动扫描(标签有缓存,通常很快),否则恢复的筛选无内容可筛
if (view.value === 'local') void store.scanLocal().catch(() => {})
} finally {
// 挂载初始化结束才让「视图 / 关键字变化 → 补齐分页」的监听生效,
// 避免它与上面的首页请求并发(并发会因列表整体替换而丢页);
// 随后按同一串行顺序补一次,覆盖「启动时已带关键字」的情况
mountedReady = true
ensureFeiniuFilterCoverage()
}
})
/** 表格表头与 TrackItem 行保持同一条网格 */
@@ -169,10 +327,9 @@ const GRID = { gridTemplateColumns: '28px 36px minmax(0,1fr) 74px 28px' }
const listEmptyHint = computed(() => {
if (view.value === 'feiniu') {
if (store.searchKeyword.trim()) return '没有匹配「' + store.searchKeyword.trim() + '」的曲目'
return store.config.loggedIn ? '曲库为空,试试刷新或检查 NAS 曲库目录' : '请先在「设置 → 飞牛音乐连接」登录'
}
return '暂无本地音乐,点击「扫描本地曲库」或先到「发现音乐」下载'
return '暂无本地音乐,点击「扫描本地曲库」,或到「设置 → 存储与上传」添加本地曲库目录'
})
</script>
@@ -249,15 +406,19 @@ const listEmptyHint = computed(() => {
<!-- ===== 内容key 随来源变化复用全局 tab-animate 切换动画 ===== -->
<section :key="view" class="tab-animate flex min-w-0 flex-1 flex-col">
<!-- 未登录态 -->
<div v-if="needsLogin" class="flex min-h-0 flex-1 items-center justify-center px-6">
<!-- 未登录态**只挡飞牛曲库**本地曲库与我的歌单完全不依赖 NAS 登录
此前把它们一起挡掉等于让只用发现音乐 + 本地曲库的用户点进来只看得到登录提示 -->
<div
v-if="view === 'feiniu' && needsLogin"
class="flex min-h-0 flex-1 items-center justify-center px-6"
>
<Empty>
<EmptyMedia>
<Music2 class="size-8 text-muted-foreground" />
</EmptyMedia>
<EmptyTitle>还没有可用的飞牛音乐连接</EmptyTitle>
<EmptyTitle>飞牛曲库需要一个可用的音乐连接</EmptyTitle>
<EmptyDescription>
设置 飞牛音乐连接添加并登录或先到发现音乐下载歌曲到本地曲库
设置 飞牛音乐连接添加并登录本地曲库与我的歌单不依赖 NAS可直接在左侧切换查看
</EmptyDescription>
</Empty>
</div>
@@ -266,36 +427,65 @@ const listEmptyHint = computed(() => {
<!-- 工具栏 -->
<header class="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
<template v-if="view === 'feiniu'">
<div class="relative w-full max-w-sm">
<div class="relative w-full max-w-[280px]">
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="store.searchKeyword"
class="h-8 pl-8"
placeholder="搜索歌名 / 歌手 / 专辑"
placeholder="搜索歌名 / 歌手 / 专辑(本地筛选)"
@input="onSearchInput"
@keydown.enter="onSearchInput"
/>
</div>
<Button variant="outline" size="sm" class="h-8" :disabled="store.loading" @click="refreshFeiniu">
<RefreshCw :class="store.loading ? 'size-3.5 animate-spin' : 'size-3.5'" />
<Button
variant="outline"
size="sm"
class="h-8"
:disabled="store.loading || store.refreshing"
@click="refreshFeiniu"
>
<RefreshCw :class="store.loading || store.refreshing ? 'size-3.5 animate-spin' : 'size-3.5'" />
刷新
</Button>
<span class="ml-1 text-[11.5px] text-muted-foreground">
<span class="ml-1 shrink-0 whitespace-nowrap text-[11.5px] text-muted-foreground">
<template v-if="isFiltering">
匹配 {{ filteredTracks.length }} / 已加载 {{ store.tracks.length }}
</template>
<template v-else>
{{ store.tracks.length }}<template v-if="store.total > store.tracks.length"> / {{ store.total }}</template>
</template>
</span>
<Button size="sm" class="ml-auto h-8" :disabled="!store.tracks.length" @click="playFeiniu">
<Button size="sm" class="ml-auto h-8" :disabled="!filteredTracks.length" @click="playFeiniu">
<Play class="size-3.5" /> 播放全部
</Button>
</template>
<template v-else-if="view === 'local'">
<!-- 结构与飞牛曲库完全一致搜索框 操作按钮 计数 播放全部
提示语与筛选字段歌名 / 歌手 / 专辑也保持一致
宽度收窄到与飞牛曲库同宽把余量留给右侧的匹配数与播放全部 -->
<div class="relative w-full max-w-[280px]">
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
v-model="store.searchKeyword"
class="h-8 pl-8"
placeholder="搜索歌名 / 歌手 / 专辑(本地筛选)"
@input="onSearchInput"
@keydown.enter="onSearchInput"
/>
</div>
<Button variant="outline" size="sm" class="h-8" :disabled="store.localScanBusy" @click="refreshLocal">
<Loader2 v-if="store.localScanBusy" class="size-3.5 animate-spin" />
<FolderOpen v-else class="size-3.5" />
扫描本地曲库
</Button>
<span class="text-[11.5px] text-muted-foreground">{{ store.localTracks.length }} </span>
<Button size="sm" class="ml-auto h-8" :disabled="!store.localTracks.length" @click="playLocal">
<span class="ml-1 shrink-0 whitespace-nowrap text-[11.5px] text-muted-foreground">
<template v-if="isFiltering">
匹配 {{ filteredLocalTracks.length }} / 已扫描 {{ store.localTracks.length }}
</template>
<template v-else>{{ store.localTracks.length }} </template>
</span>
<Button size="sm" class="ml-auto h-8" :disabled="!filteredLocalTracks.length" @click="playLocal">
<Play class="size-3.5" /> 播放全部
</Button>
</template>
@@ -344,8 +534,8 @@ const listEmptyHint = computed(() => {
<!-- 表头 -->
<div
v-if="
(view === 'feiniu' && store.tracks.length) ||
(view === 'local' && store.localTracks.length) ||
(view === 'feiniu' && filteredTracks.length) ||
(view === 'local' && filteredLocalTracks.length) ||
(view === 'playlists' && activePlaylist?.items.length)
"
class="grid h-8 items-center gap-3 px-2 text-[11px] text-muted-foreground"
@@ -365,26 +555,25 @@ const listEmptyHint = computed(() => {
</div>
<Empty v-else-if="!store.tracks.length">
<EmptyMedia><Music2 class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>{{ store.searchKeyword.trim() ? '没有匹配的曲目' : '曲库为空' }}</EmptyTitle>
<EmptyTitle>曲库为空</EmptyTitle>
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
</Empty>
<Empty v-else-if="!filteredTracks.length">
<EmptyMedia><Search class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>没有匹配「{{ store.searchKeyword.trim() }}」的曲目</EmptyTitle>
<EmptyDescription>
已在{{ store.tracks.length }}首曲库中筛选(歌名 / 歌手 / 专辑)。
</EmptyDescription>
</Empty>
<template v-else>
<TrackItem
v-for="(t, i) in store.tracks"
v-for="(t, i) in visibleFeiniu"
:key="t.guid || i"
:item="t"
:index="i"
:context="store.tracks"
:context="filteredTracks"
:active="store.current?.guid === t.guid"
/>
<div
v-if="store.hasMoreTracks"
ref="loadMoreRef"
class="flex items-center justify-center gap-2 py-4 text-[12px] text-muted-foreground"
>
<Loader2 v-if="store.loadingMore" class="size-3.5 animate-spin" />
正在加载更多…
</div>
</template>
</template>
@@ -395,13 +584,18 @@ const listEmptyHint = computed(() => {
<EmptyTitle>暂无本地音乐</EmptyTitle>
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
</Empty>
<template v-else>
<Empty v-else-if="store.localTracks.length && !filteredLocalTracks.length">
<EmptyMedia><Search class="size-8 text-muted-foreground" /></EmptyMedia>
<EmptyTitle>没有匹配「{{ store.searchKeyword.trim() }}」的本地曲目</EmptyTitle>
<EmptyDescription>已在扫描到的 {{ store.localTracks.length }} 首中筛选。</EmptyDescription>
</Empty>
<template v-else-if="filteredLocalTracks.length">
<TrackItem
v-for="(t, i) in store.localTracks"
v-for="(t, i) in visibleLocal"
:key="t.guid || i"
:item="t"
:index="i"
:context="store.localTracks"
:context="filteredLocalTracks"
:active="store.current?.guid === t.guid"
/>
</template>
@@ -422,7 +616,7 @@ const listEmptyHint = computed(() => {
</Button>
</Empty>
<TrackItem
v-for="(t, i) in activePlaylist.items"
v-for="(t, i) in visiblePlaylistItems"
:key="`${t.source}:${t.guid}:${i}`"
:item="t"
:index="i"
@@ -439,6 +633,17 @@ const listEmptyHint = computed(() => {
<EmptyDescription>点击左侧「新建歌单」开始整理你的收藏。</EmptyDescription>
</Empty>
</template>
<!-- 增量渲染 / 分页共用哨兵:滚到这里先追加一批行,行渲染完了再向 NAS 要下一页 -->
<div
v-if="showSentinel && activeList.length"
ref="loadMoreRef"
class="flex items-center justify-center gap-2 py-4 text-[12px] text-muted-foreground"
>
<Loader2 v-if="store.loadingMore" class="size-3.5 animate-spin" />
<template v-if="hasMoreRows">已显示 {{ shownRows }} / {{ activeList.length }} 首,继续滚动加载</template>
<template v-else>正在加载更多…</template>
</div>
</div>
</ScrollArea>
</template>
+13 -11
View File
@@ -106,26 +106,26 @@ async function uploadToFeiniu() {
<template>
<div
class="group grid h-11 items-center gap-3 rounded-md px-2 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring/50"
class="group grid h-11 items-center gap-3 rounded-md px-2 transition-colors"
:class="[
showAlbum
? 'grid-cols-[28px_36px_minmax(0,1fr)_minmax(0,0.75fr)_74px_28px]'
: 'grid-cols-[28px_36px_minmax(0,1fr)_74px_28px]',
isCurrent ? 'bg-accent/60' : 'hover:bg-accent/40'
]"
role="button"
tabindex="0"
@dblclick="play"
@keydown.enter.prevent="play"
>
<!-- 序号 / 播放态指示悬停时原位切换不占用固定的空白列 -->
<div class="flex size-7 items-center justify-center text-[11.5px] tabular-nums text-muted-foreground">
<!-- 序号 / 播放态指示 / 播放按钮
行本身不再是 role=button内部还嵌着下拉菜单的按钮嵌套交互元素对读屏是噪音
播放改由下面这个**常驻 DOM** 的按钮承担原来它是 hover display:noneblock
display:none 的元素无法获得焦点键盘用户根本按不到 -->
<div class="relative flex size-7 items-center justify-center text-[11.5px] tabular-nums text-muted-foreground">
<span v-if="isCurrent" class="music-eq" :class="{ 'is-paused': !store.playing }"><i /><i /><i /></span>
<template v-else>
<span class="group-hover:hidden">{{ index + 1 }}</span>
<span class="transition-opacity group-hover:opacity-0">{{ index + 1 }}</span>
<button
type="button"
class="hidden text-foreground group-hover:block"
class="absolute inset-0 flex items-center justify-center rounded text-foreground opacity-0 outline-none transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring/50"
:aria-label="'播放 ' + item.title"
@click.stop="play"
>
@@ -223,9 +223,11 @@ async function uploadToFeiniu() {
>
<Cloud class="size-4" /> 上传到飞牛曲库
</DropdownMenuItem>
<DropdownMenuItem disabled>
<FolderOpen class="size-4" /> {{ item.dir || '未知目录' }}
</DropdownMenuItem>
<!-- 所在目录只是信息不是动作 disabled 的菜单项展示会让人以为点了没反应 -->
<div class="flex items-center gap-2 px-2 py-1.5 text-[11.5px] text-muted-foreground">
<FolderOpen class="size-3.5 shrink-0" />
<span class="truncate" :title="item.dir || ''">{{ item.dir || '未知目录' }}</span>
</div>
</template>
<template v-if="playlistId">
@@ -22,6 +22,7 @@ const editing = ref<Partial<FeiniuConnection> | null>(null)
const editOpen = ref(false)
const password = ref('')
const testing = ref(false)
const saving = ref(false)
function newForm() {
editing.value = { name: '', kind: 'lan', baseUrl: '', username: '', accessCode: '', insecure: false, fnId: '' }
@@ -30,46 +31,94 @@ function newForm() {
}
function editForm(c: FeiniuConnection) {
editing.value = { ...c }
// 历史数据里的 frp 等内网转发方式已并入「局域网」
editing.value = { ...c, kind: c.kind === 'fnconnect' ? 'fnconnect' : 'lan' }
password.value = ''
editOpen.value = true
}
async function save() {
if (!editing.value) return
if (!editing.value.name?.trim() || !editing.value.baseUrl?.trim()) {
toast.error('请填写名称与服务器地址')
if (!editing.value || saving.value) return
const draft = { ...editing.value }
if (!draft.name?.trim()) {
toast.error('请填写名称')
return
}
// 局域网需要服务器地址;FnConnect 需要飞牛 ID(服务器地址在登录时自动解析)
if (draft.kind === 'fnconnect') {
if (!draft.fnId?.trim()) {
toast.error('请填写飞牛 ID')
return
}
} else if (!draft.baseUrl?.trim()) {
toast.error('请填写服务器地址')
return
}
const pw = password.value
saving.value = true
try {
const id = await store.saveConnection({
id: editing.value.id || '',
name: editing.value.name.trim(),
kind: editing.value.kind || 'lan',
baseUrl: editing.value.baseUrl.trim(),
username: editing.value.username || '',
accessCode: editing.value.accessCode || '',
insecure: !!editing.value.insecure,
fnId: editing.value.fnId || ''
id: draft.id || '',
name: draft.name.trim(),
kind: draft.kind || 'lan',
baseUrl: draft.baseUrl?.trim() || '',
username: draft.username || '',
accessCode: draft.accessCode || '',
insecure: !!draft.insecure,
fnId: draft.fnId?.trim() || '',
relay: !!draft.relay
})
if (password.value) {
await store.login(id, editing.value.username || '', password.value)
}
// 先关闭对话框再登录:FnConnect 登录要先探测可达地址(可能数秒),
// 若等待其完成才关窗,观感就是「已保存但对话框卡住」。
editOpen.value = false
toast.success('已保存')
if (pw && id) {
void store
.login(id, draft.username || '', pw)
.then(() => toast.success('登录成功'))
.catch((e) => toast.error(`登录失败:${e}`))
}
} catch (e) {
toast.error(String(e))
} finally {
saving.value = false
}
}
async function test(c: FeiniuConnection) {
/** 测试当前对话框里的草案(无需先保存,新建连接也可以直接测) */
async function test() {
const d = editing.value
if (!d || testing.value) return
if (!password.value) {
toast.error('请输入密码再测试')
return
}
if (d.kind === 'fnconnect') {
if (!d.fnId?.trim()) {
toast.error('请填写飞牛 ID')
return
}
} else if (!d.baseUrl?.trim()) {
toast.error('请填写服务器地址')
return
}
testing.value = true
try {
await store.testConnection(c.id, c.username, password.value)
await store.testConnection(
{
id: d.id || '',
name: d.name?.trim() || '测试',
kind: d.kind || 'lan',
baseUrl: d.baseUrl?.trim() || '',
username: d.username || '',
accessCode: d.accessCode || '',
insecure: !!d.insecure,
fnId: d.fnId?.trim() || '',
relay: !!d.relay
},
d.username || '',
password.value
)
toast.success('连接成功')
} catch (e) {
toast.error(String(e))
@@ -93,8 +142,9 @@ async function remove(c: FeiniuConnection) {
toast.success('已删除')
}
/** kind → 展示名:局域网涵盖 frp / 内网转发等直连方式,其余为 FnConnect */
function kindLabel(k: string) {
return k === 'lan' ? '局域网' : k === 'frp' ? 'frp 域名' : 'FnConnect'
return k === 'fnconnect' ? 'FnConnect' : '局域网'
}
onMounted(() => store.refreshConnections())
@@ -122,7 +172,8 @@ onMounted(() => store.refreshConnections())
<Badge v-if="store.activeId === c.id" variant="default" class="text-[10px]">激活</Badge>
</div>
<div class="truncate text-xs text-muted-foreground">
{{ c.baseUrl }}<template v-if="c.username"> · {{ c.username }}</template>
{{ c.baseUrl }}<template v-if="c.kind === 'fnconnect' && c.relay"> · 中继</template
><template v-if="c.username"> · {{ c.username }}</template>
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
@@ -167,7 +218,7 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<p v-if="!store.connections.length" class="text-xs text-muted-foreground">
还没有连接新建一个并填写 NAS 地址局域网 http://192.168.x.x:5666frp 域名 FnConnect fnId
还没有连接新建一个并填写 NAS 地址局域网 http://192.168.x.x:5666frp 等内网转发同样填最终访问地址 FnConnect fnId
</p>
</div>
@@ -179,17 +230,17 @@ onMounted(() => store.refreshConnections())
<div class="flex flex-col gap-3">
<div class="space-y-1.5">
<Label>名称</Label>
<Input v-model="editing!.name" placeholder="如:家里 NAS / frp 远程 / FnConnect" />
<Input v-model="editing!.name" placeholder="如:家里 NAS / FnConnect 远程" />
</div>
<div class="space-y-1.5">
<Label>类型</Label>
<div class="flex gap-2">
<Button
v-for="k in (['lan', 'frp', 'fnconnect'] as const)"
v-for="k in (['lan', 'fnconnect'] as const)"
:key="k"
type="button"
size="sm"
:variant="editing!.kind === k ? 'default' : 'outline'"
:variant="(editing!.kind ?? 'lan') === k ? 'default' : 'outline'"
@click="editing!.kind = k"
>
{{ kindLabel(k) }}
@@ -197,15 +248,19 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<div v-if="editing!.kind === 'fnconnect'" class="space-y-1.5">
<Label>FnConnect fnIdfnos.net/xxx 或裸 id</Label>
<Input v-model="editing!.fnId" placeholder="fnos.net/zy2060537" />
<Label>飞牛 ID</Label>
<Input v-model="editing!.fnId" placeholder="请输入飞牛 ID,如 abc123" />
<p class="text-xs text-muted-foreground">
保存后点登录会自动解析到可达地址服务器地址会回填
fnos.net/ 后面的那段也可直接粘贴 fnos.net/abc123
保存后点登录会自动解析到可达地址内网 / 公网 / 中继服务器地址会回填
</p>
</div>
<div v-else class="space-y-1.5">
<Label>服务器地址</Label>
<Input v-model="editing!.baseUrl" placeholder="http://192.168.1.10:5666 或 https://xxx.xxx.com" />
<Input
v-model="editing!.baseUrl"
placeholder="http://192.168.1.10:5666 或 https://xxx.xxx.comfrp 填转发后的地址)"
/>
</div>
<div class="space-y-1.5">
<Label>账号</Label>
@@ -221,17 +276,14 @@ onMounted(() => store.refreshConnections())
</div>
</div>
<DialogFooter class="gap-2">
<Button variant="outline" :disabled="testing" @click="test(editing as unknown as FeiniuConnection)">
<Button variant="outline" :disabled="testing || saving" @click="test">
<Loader2 v-if="testing" class="size-4 animate-spin" /> 测试
</Button>
<Button @click="save">保存</Button>
<Button :disabled="saving" @click="save">
<Loader2 v-if="saving" class="size-4 animate-spin" /> 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<p class="text-xs text-muted-foreground">
提示上传音乐到 NAS下载到飞牛/ 上传 / 删除已改用 <strong class="font-medium">WebDAV</strong>
存储与上传分组里配置无需在此登录文件服务
</p>
</div>
</template>
+720 -66
View File
File diff suppressed because it is too large Load Diff
+31 -5
View File
@@ -31,6 +31,17 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 待跳转 tab(搜索导航设置):{ moduleId, tab },模块挂载/已挂载时消费 */
const pendingTab = ref<{ moduleId: string; tab: string } | null>(null)
/**
* 各模块上次停留的 tab(内存记忆)。
*
* 模块被切走时组件卸载、`activeTab` 随之丢失,再切回来总是回到第一个 tab——
* 对「曲库 / 发现音乐 / 设置」这种三级结构的模块尤其别扭。
* 只做内存记忆(不落盘):tab 集合可能随版本变化,跨版本恢复旧值反而容易出错。
*/
const lastTabByModule = ref<Record<string, string>>({})
/** 当前已注册的模块 idunregister 时据此保存) */
let currentModuleId = ''
/** 设置待跳转 tab(搜索结果点击时调用) */
const setPendingTab = (moduleId: string, tab: string) => {
pendingTab.value = { moduleId, tab }
@@ -56,15 +67,30 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
/** 是否显示保存按钮(当前在设置 tab 且注册了保存处理函数) */
const saveVisible = computed(() => saveHandler.value !== null && activeTab.value === 'settings')
/** 模块注册标签(onMounted 时调用) */
const registerTabs = (tabList: ModuleTab[], current: string) => {
/**
* 模块注册标签(onMounted 时调用)。
* 返回该模块**应当使用**的 tab:优先复用上次停留的(且在当前 tab 集合内),
* 否则用传入的初始值。调用方若拿到不同值,需同步自己的 activeTab。
*/
const registerTabs = (moduleId: string, tabList: ModuleTab[], current: string): string => {
currentModuleId = moduleId
tabs.value = tabList
activeTab.value = current
const remembered = lastTabByModule.value[moduleId]
const restored =
remembered && tabList.some((t) => t.value === remembered) ? remembered : current
activeTab.value = restored
floatingVisible.value = false
return restored
}
/** 模块注销标签(onUnmounted 时调用) */
const unregisterTabs = () => {
/**
* 模块注销标签(onUnmounted 时调用)。
* `moduleId` 缺省时用当前注册的模块(保留旧调用点的语义)。
*/
const unregisterTabs = (moduleId?: string) => {
const id = moduleId ?? currentModuleId
if (id && activeTab.value) lastTabByModule.value[id] = activeTab.value
currentModuleId = ''
tabs.value = []
activeTab.value = ''
floatingVisible.value = false
+201 -30
View File
@@ -119,6 +119,8 @@ const logger = createLogger('music')
export const useMusicStore = defineStore('music', () => {
const settings = ref<MusicSettings | null>(null)
const env = ref<MusicEnvStatus | null>(null)
/** 环境探测进行中(探测要串行 spawn 4 个子进程,便携 Python 冷启动可达数秒) */
const envLoading = ref(false)
/** musicdl 已注册的全部搜索源(客户端名) */
const availableSources = ref<string[]>([])
@@ -136,23 +138,118 @@ export const useMusicStore = defineStore('music', () => {
const installError = ref('')
let progressUnlisten: UnlistenFn | null = null
async function init() {
/**
* 幂等初始化。
*
* 标题栏音乐控件与音乐模块各会调用一次:不做守卫就会**重复**执行
* `restoreTasks()` 与 `refreshEnv()`,而后者每次要串行 spawn 4 个探测子进程
* (便携 Python 冷启动可达数秒)——表现为每次进入音乐模块都重新探测一遍环境、
* 环境面板反复闪「检测中」。失败时清空在途 Promise,允许下次重试。
*/
let initPromise: Promise<void> | null = null
function init(): Promise<void> {
if (initPromise) return initPromise
initPromise = (async () => {
restoreTasks()
await Promise.all([loadSettings(), refreshEnv()])
})().catch((e) => {
initPromise = null
throw e
})
return initPromise
}
/**
* QQ 音乐 Cookie 存放在**系统凭据管理器**Rust 侧 `music_secret_*`),
* 不再明文写进 settings.json —— 与 WebDAV 密码、飞牛 token 保持同一安全姿态。
* 这里保留内存副本供同步读取(`currentQqCookie()`)。
*/
const QQ_COOKIE_KEY = 'music-qq-cookie'
const qqCookie = ref('')
let qqCookieTimer: ReturnType<typeof setTimeout> | null = null
async function loadQqCookie() {
try {
const r = (await invoke('music_secret_get', { key: QQ_COOKIE_KEY })) as { value: string | null }
qqCookie.value = r?.value ?? ''
} catch (e) {
// 凭据库不可用:退回 settings 里的明文(若历史版本留下过)
logger.error(`读取 QQ 音乐 Cookie 失败: ${e}`)
qqCookie.value = settings.value?.qqCookie ?? ''
}
}
/** 写入 QQ 音乐 Cookie(节流落盘;空串表示清除) */
function setQqCookie(v: string) {
qqCookie.value = v.trim()
if (qqCookieTimer) clearTimeout(qqCookieTimer)
qqCookieTimer = setTimeout(() => {
qqCookieTimer = null
invoke('music_secret_set', { key: QQ_COOKIE_KEY, value: qqCookie.value }).catch((e) =>
logger.error(`保存 QQ 音乐 Cookie 失败: ${e}`)
)
}, 600)
}
/**
* 一次性迁移:旧版本把 Cookie 明文写在 `settings.qqCookie`。
* 先写凭据库成功,再清字段并落盘;写失败就保留明文(功能优先,不丢用户配置)。
*/
async function migrateQqCookie() {
const legacy = settings.value?.qqCookie?.trim() ?? ''
if (!legacy) {
await loadQqCookie()
return
}
try {
await invoke('music_secret_set', { key: QQ_COOKIE_KEY, value: legacy })
qqCookie.value = legacy
if (settings.value) {
settings.value.qqCookie = ''
await saveSettings()
}
} catch (e) {
logger.error(`迁移 QQ 音乐 Cookie 到凭据管理器失败(保留明文): ${e}`)
qqCookie.value = legacy
}
}
/**
* 最近一次与后端一致的设置快照(JSON)。
* `musicGetSettings()` 读回来的对象会被 `watch(store.settings, {deep:true})` 观察到,
* 若不比对就会把刚读到的内容原样写回——既是无谓写盘,又可能用旧快照覆盖后端新状态。
*/
let syncedSettingsJson = ''
async function loadSettings() {
settings.value = await commands.musicGetSettings()
syncedSettingsJson = settings.value ? JSON.stringify(settings.value) : ''
// Cookie 的真身在系统凭据管理器:先做一次性迁移,再从凭据库读回内存
await migrateQqCookie()
}
/** 相对上次读取/保存是否有真实改动(供 debounce 保存前判断,避免回写快照) */
function settingsChanged(): boolean {
if (!settings.value) return false
return JSON.stringify(settings.value) !== syncedSettingsJson
}
/** 保存设置(调用方先修改 settings 再调用;前端用 400ms debounce */
async function saveSettings() {
if (!settings.value) return
await commands.musicSaveSettings(settings.value)
syncedSettingsJson = JSON.stringify(settings.value)
}
async function refreshEnv() {
// 探测期间 `env` 可能是旧的/为 null,UI 必须靠这个标志区分
// 「正在检测」与「确实未安装」,否则冷启动会短暂误报「未安装」并诱导用户重装
envLoading.value = true
try {
env.value = await commands.musicEnvStatus()
} finally {
envLoading.value = false
}
}
async function loadSources() {
@@ -169,11 +266,41 @@ export const useMusicStore = defineStore('music', () => {
return (await invoke('music_ping')) as MusicPingPayload
}
/**
* 音乐请求(搜索 / 解析 / 下载)该使用的代理地址。
*
* 未开「搜索/下载走代理」时返回**空串**,桥接侧据此强制直连并屏蔽系统代理——
* 这一点必须显式传:requests 缺省会读系统代理(Windows 下连注册表的
* Internet Settings 都会读),用户一开代理模块的系统代理,搜索就会被静默
* 送进 mihomo,国内源无结果。
*/
function currentProxyUrl(): string {
if (!settings.value?.useProxy) return ''
const port = useProxyStore().settings?.mixedPort
return port ? `http://127.0.0.1:${port}` : ''
}
/**
* QQ 音乐 Cookie(可选,用户在设置里粘贴)。
* 用于:解析需要登录的歌单(含自己的隐私歌单)、VIP 音质(无损)下载。
* 桥接侧只在配置了 Cookie 时注入(musicdl 的既有逻辑:配了 Cookie 的源
* 会跳过第三方解析源,全部走官方接口)。
* 真身存在系统凭据管理器,这里只返回内存副本。
*/
function currentQqCookie(): string {
return qqCookie.value.trim()
}
async function search(keyword: string, sources: string[]) {
searching.value = true
searchError.value = ''
try {
const v = (await invoke('music_search', { keyword, sources })) as MusicSearchPayload
const v = (await invoke('music_search', {
keyword,
sources,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as MusicSearchPayload
results.value = v.results ?? {}
resultTotal.value = v.total ?? 0
searched.value = true
@@ -194,7 +321,12 @@ export const useMusicStore = defineStore('music', () => {
parsing.value = true
parseError.value = ''
try {
const v = (await invoke('music_parse_playlist', { url, sources })) as {
const v = (await invoke('music_parse_playlist', {
url,
sources,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as {
songs: MusicSong[]
count: number
}
@@ -376,7 +508,12 @@ export const useMusicStore = defineStore('music', () => {
if (!song) return null
if (song.downloadUrl) return song
if (!song.rawSearch) return null
const v = (await invoke('music_resolve', { song, quality })) as {
const v = (await invoke('music_resolve', {
song,
quality,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as {
songs: (MusicSong | null)[]
}
const resolved = v.songs?.[0]
@@ -412,7 +549,12 @@ export const useMusicStore = defineStore('music', () => {
const lazy = songs.filter((s) => !s.downloadUrl && s.rawSearch)
if (lazy.length > 0) {
try {
const v = (await invoke('music_resolve', { songs: lazy, quality })) as {
const v = (await invoke('music_resolve', {
songs: lazy,
quality,
proxyUrl: currentProxyUrl(),
qqCookie: currentQqCookie()
})) as {
songs: (MusicSong | null)[]
}
// 返回与输入顺序对齐,失败位为 null
@@ -479,6 +621,7 @@ export const useMusicStore = defineStore('music', () => {
lyric: opts.lyric,
cover: opts.cover,
proxyUrl: opts.proxyUrl,
qqCookie: currentQqCookie(),
maxConcurrent: opts.maxConcurrent,
quality
})
@@ -526,17 +669,11 @@ export const useMusicStore = defineStore('music', () => {
}
const s = settings.value
if (!s) return
// 代理 URL:惰性读取代理模块设置(避免 store 初始化时跨 store 依赖)
let proxy = ''
if (s.useProxy) {
const port = useProxyStore().settings?.mixedPort
proxy = port ? `http://127.0.0.1:${port}` : ''
}
await startDownload(task.songsData, {
savedir: s.savedir,
lyric: s.lyricDownload,
cover: s.coverDownload,
proxyUrl: proxy,
proxyUrl: currentProxyUrl(),
engine: s.downloadEngine,
maxConcurrent: s.maxConcurrent,
// 沿用原任务的目标音质("最高" 存为空串),而不是当前默认音质
@@ -544,32 +681,59 @@ export const useMusicStore = defineStore('music', () => {
})
}
async function installRuntime() {
if (installing.value) return
installing.value = true
installError.value = ''
installProgress.value = null
if (!progressUnlisten) {
progressUnlisten = await listen<MusicInstallProgress>(
'music-runtime-install-progress',
(e) => {
/** 挂上运行时安装 / 更新的进度监听(两个动作共用同一条事件通道) */
async function attachRuntimeProgress() {
if (progressUnlisten) return
progressUnlisten = await listen<MusicInstallProgress>('music-runtime-install-progress', (e) => {
installProgress.value = e.payload
})
}
)
}
try {
env.value = await commands.musicInstallRuntime()
} catch (e) {
// 保留失败原因供设置页面板展示;进度停在最后事件值,需清掉避免误导
installError.value = typeof e === 'string' ? e : JSON.stringify(e)
throw e
} finally {
/** 动作收尾:摘掉监听。失败原因留下面板展示;进度停在最后事件值需清掉,避免误导 */
function detachRuntimeProgress(err: unknown) {
installing.value = false
installError.value = err ? (typeof err === 'string' ? err : JSON.stringify(err)) : ''
if (progressUnlisten) {
progressUnlisten()
progressUnlisten = null
}
}
async function installRuntime() {
if (installing.value) return
installing.value = true
installError.value = ''
installProgress.value = null
try {
await attachRuntimeProgress()
env.value = await commands.musicInstallRuntime()
} catch (e) {
detachRuntimeProgress(e)
throw e
}
detachRuntimeProgress(null)
}
/**
* 把 musicdl 对齐到本应用锁定的版本(`force = true` 为强制重装)。
*
* 存在的理由:安装流程的闸门是「能否 import」,不看版本,所以仅升级应用
* 不会让已装环境换版本;版本不一致时必须由用户显式触发这次更新。
* 只升到锁定版本,不升 PyPI 最新(bridge.py 的补丁与 musicdl 版本强耦合)。
*/
async function updateMusicdl(force = false) {
if (installing.value) return
installing.value = true
installError.value = ''
installProgress.value = null
try {
await attachRuntimeProgress()
env.value = (await invoke('music_update_musicdl', { force })) as MusicEnvStatus
} catch (e) {
detachRuntimeProgress(e)
throw e
}
detachRuntimeProgress(null)
}
function cancelInstall() {
@@ -583,6 +747,9 @@ export const useMusicStore = defineStore('music', () => {
return {
settings,
env,
envLoading,
qqCookie,
setQqCookie,
availableSources,
searching,
searchError,
@@ -597,10 +764,13 @@ export const useMusicStore = defineStore('music', () => {
tasks,
init,
loadSettings,
settingsChanged,
saveSettings,
refreshEnv,
loadSources,
pingBridge,
currentProxyUrl,
currentQqCookie,
search,
parsePlaylist,
resolveSong,
@@ -609,6 +779,7 @@ export const useMusicStore = defineStore('music', () => {
removeTask,
redownloadTask,
installRuntime,
updateMusicdl,
cancelInstall,
stopBridge
}
+323
View File
@@ -0,0 +1,323 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { invoke } from '@tauri-apps/api/core'
import { useMusicStore, type MusicDownloadTask, type MusicSong } from '@/stores/musicStore'
import { useFeiniuStore } from '@/stores/feiniuStore'
import { createLogger } from '@/lib/logger'
const logger = createLogger('music-upload')
/** feiniu_scan_local 返回的本地音频条目(只取用到的字段) */
interface LocalAudioFile {
path: string
/** 文件名(不含扩展名) */
name?: string
/** 音频标签里的标题 */
title?: string
/** 修改时间(Unix 秒) */
mtim?: number
}
/**
* 下载 → 上传飞牛的记账中枢。
*
* 为什么必须是一个 **store** 而不是组件内逻辑:
* 这套记账是「任务完成时触发上传」的**唯一触发点**,一旦放在 `MusicModule.vue`
* 的 setup 里,用户下载途中切走音乐模块(组件卸载 → watcher 被销毁)就永远不会触发——
* 表现为「下载到飞牛」下载完了却没上传、本地副本也没删,自动上传同样静默失效。
* Pinia 的 setup store 活在独立 effect scope 中,watcher 不随组件卸载而销毁。
*
* 记账粒度到**任务**(而非「最近 N 分钟的文件」):
* 任务行上的「上传到飞牛曲库」只应传它自己下载的文件,
* 否则会把别的任务、甚至用户手动放进下载目录的文件一起传上去。
*
* 所有上传走**同一条串行队列**`feiniu_scan_local` 要遍历整个曲库目录做标签解析,
* 并发跑两个上传等于并发扫两遍盘,还会让 `uploading` 标志提前归位(按钮状态错乱)。
*/
export const useMusicUploadStore = defineStore('musicUpload', () => {
const music = useMusicStore()
const feiniu = useFeiniuStore()
/** 行级「下载到飞牛」进行中的 key`${source}|${index}`),驱动行内 spinner */
const rowUploadingKeys = ref<Set<string>>(new Set())
/** 已上传过的任务 id:每个任务最多上传一次,避免重复全量上传 */
const handledTaskIds = new Set<string>()
/** 「下载到飞牛」的临时任务:完成后上传 → 删本地副本 → 移除记录 */
const tempTaskIds = new Set<string>()
/** taskId → 行按钮 spinner 的 key */
const tempTaskKeys = new Map<string, string>()
/** 下载目标选「飞牛」的 musicdl 任务:完成后自动上传 */
const uploadOnDoneTaskIds = new Set<string>()
/**
* 初始化完成前不触发自动上传。
* 由 UI 在 `music.init()`(恢复历史任务)之后调用 `armAfterInit()` 置真,
* 否则启动时会拿着上一轮已完成的任务重传一遍。
*/
let armed = false
function setRowUploading(key: string, on: boolean) {
const next = new Set(rowUploadingKeys.value)
if (on) next.add(key)
else next.delete(key)
rowUploadingKeys.value = next
}
function releaseRowKey(taskId: string) {
const key = tempTaskKeys.get(taskId)
tempTaskKeys.delete(taskId)
if (key) setRowUploading(key, false)
}
// ===== 上传串行队列 =====
const uploadQueue: Array<() => Promise<void>> = []
let draining = false
/**
* 入队一个上传作业并保证串行执行。
* 队列排空后再扫一遍任务列表:上传期间新完成的任务在这一轮被接上,
* 因此不需要「跳过并等下一次状态变化」这种会漏单的写法。
*/
function enqueueUpload(job: () => Promise<void>) {
uploadQueue.push(job)
if (draining) return
draining = true
void (async () => {
try {
while (uploadQueue.length > 0) {
const next = uploadQueue.shift()
if (!next) break
await next().catch((e) => {
logger.error(`上传作业失败: ${e}`)
toast.error(`上传到飞牛失败:${e}`)
})
}
} finally {
draining = false
}
resolvePendingUploads()
})()
}
/**
* 上传某个下载任务产出的音频文件。
*
* 匹配策略(从精确到宽松):
* 1. 文件名或音频标签标题命中任务内任一歌曲名;
* 2. 文件名以「歌名 - 」开头也算命中——`bridge.py::_rename_downloaded_files`
* 在下载完成后会把落盘文件从「歌名 - <标识>.ext」改名成「歌名 - 歌手.ext」;
* 3. 都不命中时退回「修改时间 ≥ 任务创建时刻」。
* 所有规则都限定在任务时间之后,因此不会带上历史文件。
*/
async function uploadTaskFiles(
task: MusicDownloadTask,
deleteLocal: boolean
): Promise<{ total: number; ok: number; deleted: number }> {
if (!feiniu.webdavReady) throw new Error('请先在「设置 → 存储与上传」完成 WebDAV 配置')
// 同步置位:UI 的「上传中」禁用态依赖它
feiniu.uploading = true
try {
// 只列举下载目录(非递归,不解析标签):musicdl 的落盘位置恒为 savedir
// bridge.py `si.work_dir = savedir`)。用 feiniu_scan_local 会递归遍历整个
// 曲库目录并解析标签——为一次上传扫全库是纯浪费。
const dir = task.savedir || music.settings?.savedir || ''
if (!dir) throw new Error('未配置下载目录,无法定位下载产物')
const r = await invoke<{ items: LocalAudioFile[] }>('feiniu_list_audio_files', { dir })
const items = r?.items ?? []
// 任务创建时刻(毫秒)→ 秒,留 5s 余量吸收文件系统时间戳抖动
const since = task.createdAt / 1000 - 5
const recent = items.filter((f) => (f.mtim || 0) >= since)
const names = [
...new Set(task.songsData.map((s) => (s.songName ?? '').trim().toLowerCase()).filter(Boolean))
]
const nameSet = new Set(names)
const byName =
names.length > 0
? recent.filter((f) => {
const stem = String(f.name ?? '').trim().toLowerCase()
const title = String(f.title ?? '').trim().toLowerCase()
if (nameSet.has(stem) || nameSet.has(title)) return true
return names.some((n) => stem.startsWith(`${n} - `))
})
: []
const list = byName.length > 0 ? byName : recent
let ok = 0
let deleted = 0
for (const f of list) {
const path = String(f.path)
const name = path.split(/[\\/]/).pop() || 'music.bin'
try {
await feiniu.uploadToFeiniu(path, name)
ok++
if (deleteLocal) deleted += await feiniu.deleteLocalMedia(path)
} catch (e) {
logger.error(`上传失败 ${name}: ${e}`)
}
}
return { total: list.length, ok, deleted }
} finally {
feiniu.uploading = false
}
}
/** 任务行上的「上传到飞牛曲库」:只传该任务下载的文件 */
function uploadTask(taskId: string) {
const task = music.tasks.find((t) => t.taskId === taskId)
if (!task) return
if (!feiniu.webdavReady) {
toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)')
return
}
enqueueUpload(async () => {
const { total, ok } = await uploadTaskFiles(task, false)
if (total === 0) toast.info('未找到该任务下载的文件(可能已被移动或删除)')
else toast.success(`已上传 ${ok}/${total} 首到飞牛曲库`)
})
}
/** 临时任务的收尾:上传 → 删本地副本 → 移除任务记录。失败时保留本地文件与记录供排查。 */
async function finishTempUpload(taskId: string) {
const task = music.tasks.find((t) => t.taskId === taskId)
try {
if (!task) throw new Error('任务记录已不存在')
const { total, ok, deleted } = await uploadTaskFiles(task, true)
const name = task.songs?.[0]?.songName || ''
if (ok > 0) {
toast.success(`已保存到飞牛曲库${name ? `${name}` : ''}`)
music.removeTask(taskId)
if (deleted < total) toast.info(`${total - deleted} 个本地副本未能删除(可能被占用)`)
} else if (total === 0) {
toast.error('下载完成,但未在下载目录找到新文件——请检查下载目录设置')
} else {
throw new Error('上传失败')
}
} finally {
releaseRowKey(taskId)
}
}
/**
* 单曲「下载到飞牛」:走 musicdl 下载(含引擎侧解析、代理、音质),
* 任务完成后上传并删除本地副本,最后移除任务记录。
*/
async function downloadToFeiniu(song: MusicSong, source: string, index: number) {
const key = `${source}|${index}`
if (rowUploadingKeys.value.has(key)) return
if (!feiniu.webdavReady) {
toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)')
return
}
const s = music.settings
if (!s) return
// Rust 引擎的任务在「下载器」模块管理,本模块拿不到落盘结果
if (s.downloadEngine === 'rust') {
toast.error(
'Rust 引擎的下载任务在「下载器」模块中管理,无法自动回传飞牛曲库。请改用 musicdl 引擎。'
)
return
}
setRowUploading(key, true)
try {
const { engine, skipped, taskId } = await music.startDownload([song], {
savedir: s.savedir,
lyric: s.lyricDownload,
cover: s.coverDownload,
proxyUrl: s.useProxy ? music.currentProxyUrl() : '',
engine: s.downloadEngine,
maxConcurrent: s.maxConcurrent,
quality: s.defaultDownloadQuality ?? ''
})
if (engine === 'rust' || skipped > 0) {
toast.error(skipped > 0 ? '该歌曲没有可用的下载源' : 'Rust 引擎无法回传飞牛曲库')
setRowUploading(key, false)
return
}
if (taskId) {
tempTaskIds.add(taskId)
tempTaskKeys.set(taskId, key)
} else {
setRowUploading(key, false)
}
} catch (e) {
toast.error(`启动下载失败:${e}`)
setRowUploading(key, false)
}
}
/** 标记「该任务完成后自动上传」(下载目标选了飞牛时调用) */
function markUploadOnDone(taskId: string) {
uploadOnDoneTaskIds.add(taskId)
}
/** 由 UI 在 music.init() 之后调用一次:把历史已完成任务视为已处理,然后开始接收新任务 */
function armAfterInit() {
if (armed) return
for (const t of music.tasks) if (t.status === 'done') handledTaskIds.add(t.taskId)
armed = true
}
/**
* 任务状态变化的唯一驱动:
* - 临时任务(下载到飞牛)完成 → 上传 + 删本地副本 + 移除记录;
* - 普通任务完成且要求自动上传 → 上传该任务的文件;
* - 临时任务失败/被取消/被移除 → 释放行内 spinner 并提示。
*
* 只负责「发现作业并入队」,并发控制交给串行队列。
*/
function resolvePendingUploads() {
if (!armed) return
const alive = new Set(music.tasks.map((t) => t.taskId))
for (const id of [...handledTaskIds]) if (!alive.has(id)) handledTaskIds.delete(id)
for (const t of music.tasks) {
if (t.status !== 'done') continue
if (tempTaskIds.has(t.taskId)) {
// 先摘标记再入队:队列可能同步执行,重复入队会导致同一任务上传两次
tempTaskIds.delete(t.taskId)
const id = t.taskId
enqueueUpload(() => finishTempUpload(id))
continue
}
const wanted = uploadOnDoneTaskIds.has(t.taskId) || feiniu.autoUpload
if (!wanted || handledTaskIds.has(t.taskId)) continue
handledTaskIds.add(t.taskId)
uploadOnDoneTaskIds.delete(t.taskId)
const task = t
enqueueUpload(async () => {
const { total, ok } = await uploadTaskFiles(task, false)
if (total > 0 && ok > 0) toast.success(`已上传 ${ok}/${total} 首到飞牛曲库`)
})
}
// 临时任务下载失败/被取消:释放行按钮 spinner 与临时标记,避免永久转圈
for (const t of music.tasks) {
if (t.status === 'done' || t.status === 'downloading' || t.status === 'cancelling') continue
if (!tempTaskIds.has(t.taskId)) continue
tempTaskIds.delete(t.taskId)
releaseRowKey(t.taskId)
toast.error(`下载到飞牛失败:${t.errorMessage || '任务未完成,文件保留在本地下载目录'}`)
}
// 临时任务记录被手动删除(下载中移除):同样释放 spinner
for (const id of [...tempTaskIds]) {
if (alive.has(id)) continue
tempTaskIds.delete(id)
releaseRowKey(id)
}
}
watch(
() => music.tasks.map((t) => `${t.taskId}:${t.status}`).join('|'),
resolvePendingUploads
)
return {
rowUploadingKeys,
uploadTask,
uploadTaskFiles,
downloadToFeiniu,
markUploadOnDone,
armAfterInit
}
})
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 778 KiB

-1
View File
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"error":"internal","message":".NET number values such as positive and negative infinity cannot be written as valid JSON. To make it work when using 'JsonSerializer', consider specifying 'JsonNumberHandling.AllowNamedFloatingPointLiterals' (see https://docs.microsoft.com/dotnet/api/system.text.json.serialization.jsonnumberhandling)."}