音乐模块调整
This commit is contained in:
+11
-10
@@ -54,17 +54,18 @@ $tauriConf = Join-Path $Root 'src-tauri\tauri.conf.json'
|
|||||||
$cargoToml = Join-Path $Root 'src-tauri\Cargo.toml'
|
$cargoToml = Join-Path $Root 'src-tauri\Cargo.toml'
|
||||||
$pkgJson = Join-Path $Root 'package.json'
|
$pkgJson = Join-Path $Root 'package.json'
|
||||||
|
|
||||||
$t = Get-Content $tauriConf -Raw
|
# UTF-8 安全读写:`Get-Content` 默认按系统 ANSI(如 GBK) 解码,会把本就含中文/乱码的文件二次编码损坏(曾导致
|
||||||
$t = $t -replace '("version"\s*:\s*")[^"]*(")', "`${1}$Version`${2}"
|
# Cargo.toml 的 keyring 依赖行被弄丢)。这里统一用显式 UTF-8 无 BOM 读写,保证逐字节稳定往返。
|
||||||
[System.IO.File]::WriteAllText($tauriConf, $t, (New-Object System.Text.UTF8Encoding($false)))
|
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
|
Set-Utf8Version -Path $tauriConf -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version
|
||||||
$c = $c -replace '(?m)^(version\s*=\s*")[^"]*(")', "`${1}$Version`${2}"
|
Set-Utf8Version -Path $cargoToml -Pattern '(?m)^(version\s*=\s*")[^"]*(")' -NewVersion $Version
|
||||||
[System.IO.File]::WriteAllText($cargoToml, $c, (New-Object System.Text.UTF8Encoding($false)))
|
Set-Utf8Version -Path $pkgJson -Pattern '("version"\s*:\s*")[^"]*(")' -NewVersion $Version
|
||||||
|
|
||||||
$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)))
|
|
||||||
Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json"
|
Write-Step "版本号已同步:tauri.conf.json / Cargo.toml / package.json"
|
||||||
|
|
||||||
# ---------- 2. 构建 ----------
|
# ---------- 2. 构建 ----------
|
||||||
|
|||||||
Generated
+36
@@ -3318,6 +3318,32 @@ dependencies = [
|
|||||||
"scopeguard",
|
"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]]
|
[[package]]
|
||||||
name = "log"
|
name = "log"
|
||||||
version = "0.4.33"
|
version = "0.4.33"
|
||||||
@@ -3934,6 +3960,15 @@ dependencies = [
|
|||||||
"objc2-foundation",
|
"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]]
|
[[package]]
|
||||||
name = "once_cell"
|
name = "once_cell"
|
||||||
version = "1.21.4"
|
version = "1.21.4"
|
||||||
@@ -6163,6 +6198,7 @@ dependencies = [
|
|||||||
"image",
|
"image",
|
||||||
"keyring",
|
"keyring",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
|
"lofty",
|
||||||
"md-5",
|
"md-5",
|
||||||
"notify",
|
"notify",
|
||||||
"rand 0.8.8",
|
"rand 0.8.8",
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ axum = "0.7"
|
|||||||
url = "2"
|
url = "2"
|
||||||
zip = "2"
|
zip = "2"
|
||||||
dirs = "5"
|
dirs = "5"
|
||||||
|
# 音频标签解析(本地曲库元数据:标题/歌手/专辑/时长/内嵌封面)
|
||||||
|
lofty = "0.22"
|
||||||
sysinfo = "0.32"
|
sysinfo = "0.32"
|
||||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|||||||
+10
-3
@@ -45,12 +45,15 @@ use monitor_kernel::{
|
|||||||
use music::{
|
use music::{
|
||||||
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
|
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_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,
|
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_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_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,
|
music_resolve, music_save_settings, music_search, music_secret_get, music_secret_set,
|
||||||
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, MusicManager,
|
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 network_monitor::network_status;
|
||||||
use osd_window::{
|
use osd_window::{
|
||||||
@@ -261,6 +264,7 @@ pub fn run() {
|
|||||||
music_env_status,
|
music_env_status,
|
||||||
music_install_runtime,
|
music_install_runtime,
|
||||||
music_cancel_runtime_install,
|
music_cancel_runtime_install,
|
||||||
|
music_update_musicdl,
|
||||||
music_ping,
|
music_ping,
|
||||||
music_stop_bridge,
|
music_stop_bridge,
|
||||||
music_get_sources,
|
music_get_sources,
|
||||||
@@ -283,6 +287,7 @@ pub fn run() {
|
|||||||
feiniu_lyric,
|
feiniu_lyric,
|
||||||
feiniu_media_prefix,
|
feiniu_media_prefix,
|
||||||
feiniu_scan_local,
|
feiniu_scan_local,
|
||||||
|
feiniu_list_audio_files,
|
||||||
feiniu_cache_status,
|
feiniu_cache_status,
|
||||||
feiniu_cache_clear,
|
feiniu_cache_clear,
|
||||||
feiniu_cache_fetch,
|
feiniu_cache_fetch,
|
||||||
@@ -292,6 +297,8 @@ pub fn run() {
|
|||||||
webdav_delete,
|
webdav_delete,
|
||||||
webdav_get_secret,
|
webdav_get_secret,
|
||||||
webdav_save_secret,
|
webdav_save_secret,
|
||||||
|
music_secret_get,
|
||||||
|
music_secret_set,
|
||||||
feiniu_delete_local,
|
feiniu_delete_local,
|
||||||
network_status,
|
network_status,
|
||||||
osd_apply_overlay_style,
|
osd_apply_overlay_style,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+305
-66
@@ -10,12 +10,12 @@ Response (one JSON per line on stdout):
|
|||||||
|
|
||||||
Methods:
|
Methods:
|
||||||
ping -> {"version", "python"}
|
ping -> {"version", "python"}
|
||||||
env_status -> {"musicdl": version|null}
|
|
||||||
get_sources -> {"sources": [registered music client names]}
|
get_sources -> {"sources": [registered music client names]}
|
||||||
search -> {"results": {source: [song...]}, "sources": [...]}
|
search -> {"results": {source: [song...]}, "sources": [...]}
|
||||||
params: {"keyword", "sources": []}
|
params: {"keyword", "sources": [], "proxy": url-or-""}
|
||||||
resolve -> {"songs": [resolved-song-or-null, ...], "resolved": n}
|
resolve -> {"songs": [resolved-song-or-null, ...], "resolved": n}
|
||||||
params: {"song": {...}} or {"songs": [...]}
|
params: {"song": {...}} or {"songs": [...]}
|
||||||
|
可选 "proxy": url-or-""
|
||||||
(懒解析:对搜索结果歌曲执行真实解析链,返回带下载链接
|
(懒解析:对搜索结果歌曲执行真实解析链,返回带下载链接
|
||||||
的完整歌曲;输入顺序对齐,失败位为 null)
|
的完整歌曲;输入顺序对齐,失败位为 null)
|
||||||
download -> {"taskId"} (runs in background worker pool)
|
download -> {"taskId"} (runs in background worker pool)
|
||||||
@@ -62,6 +62,7 @@ import tempfile
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
# Fallback sources used when the frontend passes an empty list
|
# Fallback sources used when the frontend passes an empty list
|
||||||
DEFAULT_SOURCES = [
|
DEFAULT_SOURCES = [
|
||||||
@@ -127,6 +128,31 @@ _LAZY_URL = "http://lazy.internal/unresolved"
|
|||||||
|
|
||||||
_PATCHED = False
|
_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):
|
def _cap_timeout(timeout, stream):
|
||||||
"""按 stream 与否收紧超时,返回新的 timeout 值。"""
|
"""按 stream 与否收紧超时,返回新的 timeout 值。"""
|
||||||
@@ -157,6 +183,16 @@ def _patch_musicdl():
|
|||||||
|
|
||||||
def _capped_request(self, method, url, **kwargs):
|
def _capped_request(self, method, url, **kwargs):
|
||||||
kwargs["timeout"] = _cap_timeout(kwargs.get("timeout"), bool(kwargs.get("stream")))
|
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)
|
return _orig_request(self, method, url, **kwargs)
|
||||||
|
|
||||||
requests.Session.request = _capped_request
|
requests.Session.request = _capped_request
|
||||||
@@ -378,12 +414,27 @@ def _resolve_song(src_client, search_result, target=None):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 按目标音质解析(取 ≤ 所选的最优档)
|
# 按目标音质解析(取 ≤ 所选的最优档)
|
||||||
#
|
#
|
||||||
# musicdl 官方解析按「质量常量」循环、取首个有效档。这里在全局锁保护下临时替换
|
# musicdl 官方解析按「质量常量」循环、取首个有效档。这里临时替换各源的质量常量
|
||||||
# 各源质量常量(只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。
|
# (只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。
|
||||||
# 由于替换的是模块级常量,必须用 _RESOLVE_CAP_LOCK 串行化所有封顶解析,避免并发竞态。
|
|
||||||
# 有损码率目标用 lossless_quality_is_sufficient=False,使官方解析不采纳第三方 flac/hires。
|
# 有损码率目标用 lossless_quality_is_sufficient=False,使官方解析不采纳第三方 flac/hires。
|
||||||
|
#
|
||||||
|
# 锁的粒度是**按源**而不是一把全局锁:被替换的是各源自己模块里的常量,
|
||||||
|
# 不同源之间不会互相干扰;而一把全局锁会把并发下载里所有源的解析串起来
|
||||||
|
# (每个解析都含网络请求,持锁跨越请求 = 解析阶段吞吐退化成串行)。
|
||||||
|
# 同一源内必须串行:否则 A 恢复常量时 B 还在解析,B 会拿到未封顶的档位。
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
_RESOLVE_CAP_LOCK = threading.Lock()
|
_RESOLVE_CAP_LOCKS = {}
|
||||||
|
_RESOLVE_CAP_LOCKS_GUARD = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cap_lock(source):
|
||||||
|
"""取该源的封顶解析锁(惰性创建;字典本身受 _RESOLVE_CAP_LOCKS_GUARD 保护)。"""
|
||||||
|
with _RESOLVE_CAP_LOCKS_GUARD:
|
||||||
|
lock = _RESOLVE_CAP_LOCKS.get(source)
|
||||||
|
if lock is None:
|
||||||
|
lock = threading.Lock()
|
||||||
|
_RESOLVE_CAP_LOCKS[source] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
# QQ:SongFileType.SORTED_QUALITIES 前缀(F000=flac, O8xx/O6xx=ogg, M800=320K, M500=128K, C6xx=m4a)
|
# QQ:SongFileType.SORTED_QUALITIES 前缀(F000=flac, O8xx/O6xx=ogg, M800=320K, M500=128K, C6xx=m4a)
|
||||||
_QQ_CAPS = {
|
_QQ_CAPS = {
|
||||||
@@ -488,7 +539,7 @@ def _resolve_to_quality(src_client, search_result, target):
|
|||||||
from musicdl.modules.utils.data import SongInfo
|
from musicdl.modules.utils.data import SongInfo
|
||||||
|
|
||||||
official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1
|
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)
|
_swap_quality_constants(source, allowed)
|
||||||
try:
|
try:
|
||||||
# 空 song_info_flac + lossless_quality_is_sufficient=False:
|
# 空 song_info_flac + lossless_quality_is_sufficient=False:
|
||||||
@@ -511,28 +562,39 @@ _RESOLVE_CLIENTS = {}
|
|||||||
_RESOLVE_CLIENTS_LOCK = threading.Lock()
|
_RESOLVE_CLIENTS_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def _get_resolve_client(source):
|
def _get_resolve_client(source, cookies=None):
|
||||||
"""按源缓存的独立源客户端(resolve/试听/批量解析用),失败返回 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:
|
with _RESOLVE_CLIENTS_LOCK:
|
||||||
if source in _RESOLVE_CLIENTS:
|
if cache_key in _RESOLVE_CLIENTS:
|
||||||
return _RESOLVE_CLIENTS[source]
|
return _RESOLVE_CLIENTS[cache_key]
|
||||||
try:
|
try:
|
||||||
from musicdl.musicdl import MusicClient
|
from musicdl.musicdl import MusicClient
|
||||||
|
|
||||||
|
init_cfg = {
|
||||||
|
source: {
|
||||||
|
"maintain_session": True,
|
||||||
|
"max_retries": 1,
|
||||||
|
"work_dir": _SEARCH_WORK_DIR,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cookie_dict:
|
||||||
|
init_cfg[source].update(
|
||||||
|
{
|
||||||
|
"default_search_cookies": cookie_dict,
|
||||||
|
"default_parse_cookies": cookie_dict,
|
||||||
|
"default_download_cookies": cookie_dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
client = MusicClient(
|
client = MusicClient(
|
||||||
music_sources=[source],
|
music_sources=[source],
|
||||||
init_music_clients_cfg={
|
init_music_clients_cfg=init_cfg,
|
||||||
source: {
|
|
||||||
"maintain_session": True,
|
|
||||||
"max_retries": 1,
|
|
||||||
"work_dir": _SEARCH_WORK_DIR,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
src_client = client.music_clients.get(source)
|
src_client = client.music_clients.get(source)
|
||||||
except Exception:
|
except Exception:
|
||||||
src_client = None
|
src_client = None
|
||||||
_RESOLVE_CLIENTS[source] = src_client
|
_RESOLVE_CLIENTS[cache_key] = src_client
|
||||||
return src_client
|
return src_client
|
||||||
|
|
||||||
# Persistent MusicClient cache; keyed by the sorted source list because the
|
# 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
|
return _PROTOCOL_STDOUT if _PROTOCOL_STDOUT is not None else sys.stdout
|
||||||
|
|
||||||
|
|
||||||
def get_client(sources):
|
def _cookie_str_to_dict(value):
|
||||||
"""Return a cached MusicClient, recreating it when the source set changes."""
|
"""把浏览器复制的 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
|
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:
|
if _CLIENT is None or _CLIENT_KEY != key:
|
||||||
try:
|
try:
|
||||||
from musicdl.musicdl import MusicClient
|
from musicdl.musicdl import MusicClient
|
||||||
@@ -585,6 +668,18 @@ def get_client(sources):
|
|||||||
"maintain_session": True,
|
"maintain_session": True,
|
||||||
"max_retries": 1,
|
"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}
|
threadings = {src: _SEARCH_SIZE_PER_SOURCE for src in effective}
|
||||||
_CLIENT = MusicClient(
|
_CLIENT = MusicClient(
|
||||||
music_sources=effective,
|
music_sources=effective,
|
||||||
@@ -919,15 +1014,17 @@ def handle_search(params):
|
|||||||
if not keyword:
|
if not keyword:
|
||||||
raise ValueError("keyword 不能为空")
|
raise ValueError("keyword 不能为空")
|
||||||
sources = params.get("sources") or DEFAULT_SOURCES
|
sources = params.get("sources") or DEFAULT_SOURCES
|
||||||
client = get_client(sources)
|
# 搜索全程按模块设置决定是否走代理(缺省强制直连,避免被系统代理带走)
|
||||||
# musicdl 的 rich 进度条写 sys.stdout,必须重定向到 stderr 保持 JSON 通道干净
|
with _use_proxy(params.get("proxy")):
|
||||||
with _CLIENT_LOCK:
|
client = get_client(sources, params.get("cookies"))
|
||||||
with contextlib.redirect_stdout(sys.stderr):
|
# musicdl 的 rich 进度条写 sys.stdout,必须重定向到 stderr 保持 JSON 通道干净
|
||||||
results = client.search(keyword)
|
with _CLIENT_LOCK:
|
||||||
# 懒解析歌批量补查音质档位:QQ 一次批量详情(其余源搜索项自带档位);不逐首解析
|
with contextlib.redirect_stdout(sys.stderr):
|
||||||
for source, songs in results.items():
|
results = client.search(keyword)
|
||||||
if source == "QQMusicClient":
|
# 懒解析歌批量补查音质档位:QQ 一次批量详情(其余源搜索项自带档位);不逐首解析
|
||||||
_fill_qq_lossless_hints(songs)
|
for source, songs in results.items():
|
||||||
|
if source == "QQMusicClient":
|
||||||
|
_fill_qq_lossless_hints(songs)
|
||||||
# 过滤脏条目:source 接口偶发返回 parsed 失败/字段缺失或跑偏的条目
|
# 过滤脏条目:source 接口偶发返回 parsed 失败/字段缺失或跑偏的条目
|
||||||
# (空歌名、"NULL" 歌名/歌手、与关键词毫无关系的无关歌曲),直接剔除
|
# (空歌名、"NULL" 歌名/歌手、与关键词毫无关系的无关歌曲),直接剔除
|
||||||
out = {}
|
out = {}
|
||||||
@@ -954,22 +1051,164 @@ def handle_get_sources(params):
|
|||||||
return {"sources": keys}
|
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):
|
def handle_parse_playlist(params):
|
||||||
url = (params.get("url") or "").strip()
|
url = _normalize_netease_playlist_url((params.get("url") or "").strip())
|
||||||
if not url:
|
if not url:
|
||||||
raise ValueError("url 不能为空")
|
raise ValueError("url 不能为空")
|
||||||
sources = params.get("sources") or DEFAULT_SOURCES
|
sources = params.get("sources") or DEFAULT_SOURCES
|
||||||
client = get_client(sources)
|
# 与搜索一致:按模块设置决定是否走代理(缺省强制直连)
|
||||||
# musicdl 会按源依次尝试解析(第一个成功的源 break),rich 进度写 stdout 需重定向。
|
with _use_proxy(params.get("proxy")):
|
||||||
# 歌单解析须临时关闭懒解析:网易云歌单接口只回 trackIds(仅 id 无元数据),
|
client = get_client(sources, params.get("cookies"))
|
||||||
# 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接)
|
# 网易云:懒解析——全部曲目都返回(含元数据),下载/试听时再逐首解析链接,
|
||||||
with _CLIENT_LOCK:
|
# 与搜索体验一致;musicdl 原生实现只保留解析出链接的歌,会让歌单"少歌"。
|
||||||
_set_lazy_search(client, False)
|
lazy_songs = _parse_netease_playlist_lazy(client, url) if _is_netease_url(url) else None
|
||||||
try:
|
if lazy_songs is not None:
|
||||||
with contextlib.redirect_stdout(sys.stderr):
|
return {"songs": lazy_songs, "count": len(lazy_songs)}
|
||||||
songs = client.parseplaylist(url)
|
# 其他源沿用 musicdl 的急切解析(QQ 的实现先查查询串取 id,无此问题)。
|
||||||
finally:
|
# musicdl 会按源依次尝试解析(第一个成功的源 break),rich 进度写 stdout 需重定向。
|
||||||
_set_lazy_search(client, True)
|
# 歌单解析须临时关闭懒解析:网易云歌单接口只回 trackIds(仅 id 无元数据),
|
||||||
|
# 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接)
|
||||||
|
with _CLIENT_LOCK:
|
||||||
|
_set_lazy_search(client, False)
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stdout(sys.stderr):
|
||||||
|
songs = client.parseplaylist(url)
|
||||||
|
finally:
|
||||||
|
_set_lazy_search(client, True)
|
||||||
# 同搜索:剔除 "NULL" 元数据的脏条目(歌单场景无关键词,不做相关性过滤)
|
# 同搜索:剔除 "NULL" 元数据的脏条目(歌单场景无关键词,不做相关性过滤)
|
||||||
out = [
|
out = [
|
||||||
d
|
d
|
||||||
@@ -988,6 +1227,7 @@ def handle_resolve(params):
|
|||||||
if not songs:
|
if not songs:
|
||||||
raise ValueError("songs 不能为空")
|
raise ValueError("songs 不能为空")
|
||||||
target_quality = (params.get("quality") or "").strip()
|
target_quality = (params.get("quality") or "").strip()
|
||||||
|
resolve_cookies = params.get("cookies") or ""
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
def _resolve_one(song):
|
def _resolve_one(song):
|
||||||
@@ -995,7 +1235,7 @@ def handle_resolve(params):
|
|||||||
raw_search = song.get("rawSearch")
|
raw_search = song.get("rawSearch")
|
||||||
if not source or not isinstance(raw_search, dict):
|
if not source or not isinstance(raw_search, dict):
|
||||||
return None
|
return None
|
||||||
src_client = _get_resolve_client(source)
|
src_client = _get_resolve_client(source, resolve_cookies)
|
||||||
if src_client is None:
|
if src_client is None:
|
||||||
return None
|
return None
|
||||||
# musicdl 的 rich 进度条写 sys.stdout,重定向到 stderr 保持 JSON 通道干净
|
# musicdl 的 rich 进度条写 sys.stdout,重定向到 stderr 保持 JSON 通道干净
|
||||||
@@ -1006,7 +1246,10 @@ def handle_resolve(params):
|
|||||||
return song_to_dict(resolved)
|
return song_to_dict(resolved)
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool:
|
with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool:
|
||||||
out = list(pool.map(_resolve_one, songs))
|
# 解析同样要联网:按模块设置决定是否走代理(缺省强制直连)。
|
||||||
|
# 工作线程读取的是同一个 _CURRENT_PROXY,块内一致。
|
||||||
|
with _use_proxy(params.get("proxy")):
|
||||||
|
out = list(pool.map(_resolve_one, songs))
|
||||||
return {"songs": out, "resolved": sum(1 for r in out if r)}
|
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))
|
lyric = bool(params.get("lyric", True))
|
||||||
cover = bool(params.get("cover", True))
|
cover = bool(params.get("cover", True))
|
||||||
proxy_url = params.get("proxy") or ""
|
proxy_url = params.get("proxy") or ""
|
||||||
|
cookies = params.get("cookies") or ""
|
||||||
max_concurrent = max(1, min(int(params.get("maxConcurrent") or 1), 16))
|
max_concurrent = max(1, min(int(params.get("maxConcurrent") or 1), 16))
|
||||||
target_quality = (params.get("quality") or "").strip()
|
target_quality = (params.get("quality") or "").strip()
|
||||||
if not songs:
|
if not songs:
|
||||||
@@ -1026,7 +1270,7 @@ def handle_download(params):
|
|||||||
_TASKS[task_id] = state
|
_TASKS[task_id] = state
|
||||||
thread = threading.Thread(
|
thread = threading.Thread(
|
||||||
target=_download_supervisor,
|
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,
|
daemon=True,
|
||||||
)
|
)
|
||||||
thread.start()
|
thread.start()
|
||||||
@@ -1041,7 +1285,7 @@ def handle_cancel(params):
|
|||||||
return {"taskId": task_id, "cancelled": False}
|
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 启动工作线程,逐首领取歌曲下载。
|
"""下载监督线程:按 maxConcurrent 启动工作线程,逐首领取歌曲下载。
|
||||||
每个工作线程持有独立 MusicClient(互不共享、不与搜索客户端争锁),
|
每个工作线程持有独立 MusicClient(互不共享、不与搜索客户端争锁),
|
||||||
因此搜索期间下载照常推进;取消为队列级(当前歌曲完成,其余标记取消)。"""
|
因此搜索期间下载照常推进;取消为队列级(当前歌曲完成,其余标记取消)。"""
|
||||||
@@ -1062,11 +1306,22 @@ def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, state
|
|||||||
# maintain_session=True 复用连接(默认每请求新建 Session);
|
# maintain_session=True 复用连接(默认每请求新建 Session);
|
||||||
# max_retries=1 封顶死链重试(下载前链接均已验证)
|
# max_retries=1 封顶死链重试(下载前链接均已验证)
|
||||||
sources_list = sources or DEFAULT_SOURCES
|
sources_list = sources or DEFAULT_SOURCES
|
||||||
|
worker_cfg = {
|
||||||
|
src: {"maintain_session": True, "max_retries": 1} for src in sources_list
|
||||||
|
}
|
||||||
|
# QQ 音乐 Cookie(VIP 音质 / 官方 vkey 接口)
|
||||||
|
cookie_dict = _cookie_str_to_dict(cookies)
|
||||||
|
if cookie_dict and "QQMusicClient" in worker_cfg:
|
||||||
|
worker_cfg["QQMusicClient"].update(
|
||||||
|
{
|
||||||
|
"default_search_cookies": cookie_dict,
|
||||||
|
"default_parse_cookies": cookie_dict,
|
||||||
|
"default_download_cookies": cookie_dict,
|
||||||
|
}
|
||||||
|
)
|
||||||
client = MusicClient(
|
client = MusicClient(
|
||||||
music_sources=sources_list,
|
music_sources=sources_list,
|
||||||
init_music_clients_cfg={
|
init_music_clients_cfg=worker_cfg,
|
||||||
src: {"maintain_session": True, "max_retries": 1} for src in sources_list
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
with lock:
|
with lock:
|
||||||
@@ -1303,8 +1558,6 @@ def handle(method, params):
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"python": sys.version.split()[0],
|
"python": sys.version.split()[0],
|
||||||
}
|
}
|
||||||
if method == "env_status":
|
|
||||||
return check_env()
|
|
||||||
if method == "get_sources":
|
if method == "get_sources":
|
||||||
return handle_get_sources(params)
|
return handle_get_sources(params)
|
||||||
if method == "parse_playlist":
|
if method == "parse_playlist":
|
||||||
@@ -1320,20 +1573,6 @@ def handle(method, params):
|
|||||||
raise ValueError("unknown method: %s" % method)
|
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():
|
def main():
|
||||||
global _PROTOCOL_STDOUT
|
global _PROTOCOL_STDOUT
|
||||||
# 编码对齐:Rust 侧以 UTF-8 字节写入 stdin(serde_json 序列化不转义非 ASCII),
|
# 编码对齐:Rust 侧以 UTF-8 字节写入 stdin(serde_json 序列化不转义非 ASCII),
|
||||||
|
|||||||
+221
-71
@@ -36,6 +36,27 @@ pub fn music_cancel_runtime_install(state: State<'_, MusicManager>) -> Result<()
|
|||||||
Ok(())
|
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"};
|
/// ping 桥接进程(未启动则自动拉起),返回 {"version","python"};
|
||||||
/// 返回 Value 且未标注 specta:前端直接按 JSON 使用
|
/// 返回 Value 且未标注 specta:前端直接按 JSON 使用
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -63,8 +84,17 @@ pub async fn music_search(
|
|||||||
state: State<'_, MusicManager>,
|
state: State<'_, MusicManager>,
|
||||||
keyword: String,
|
keyword: String,
|
||||||
sources: Option<Vec<String>>,
|
sources: Option<Vec<String>>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
) -> Result<serde_json::Value, 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
|
state
|
||||||
.request_with_timeout("search", params, std::time::Duration::from_secs(90))
|
.request_with_timeout("search", params, std::time::Duration::from_secs(90))
|
||||||
.await
|
.await
|
||||||
@@ -76,8 +106,15 @@ pub async fn music_parse_playlist(
|
|||||||
state: State<'_, MusicManager>,
|
state: State<'_, MusicManager>,
|
||||||
url: String,
|
url: String,
|
||||||
sources: Option<Vec<String>>,
|
sources: Option<Vec<String>>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
) -> Result<serde_json::Value, 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
|
state
|
||||||
.request_with_timeout("parse_playlist", params, std::time::Duration::from_secs(90))
|
.request_with_timeout("parse_playlist", params, std::time::Duration::from_secs(90))
|
||||||
.await
|
.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]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[specta::specta]
|
||||||
pub fn music_save_settings(
|
pub fn music_save_settings(
|
||||||
state: State<'_, MusicManager>,
|
state: State<'_, MusicManager>,
|
||||||
settings: MusicSettings,
|
settings: MusicSettings,
|
||||||
) -> Result<(), String> {
|
) -> 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>,
|
song: Option<serde_json::Value>,
|
||||||
songs: Option<Vec<serde_json::Value>>,
|
songs: Option<Vec<serde_json::Value>>,
|
||||||
quality: Option<String>,
|
quality: Option<String>,
|
||||||
|
proxy_url: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let mut list: Vec<serde_json::Value> = songs.unwrap_or_default();
|
let mut list: Vec<serde_json::Value> = songs.unwrap_or_default();
|
||||||
if let Some(s) = song {
|
if let Some(s) = song {
|
||||||
@@ -116,7 +173,12 @@ pub async fn music_resolve(
|
|||||||
if list.is_empty() {
|
if list.is_empty() {
|
||||||
return Err("未提供歌曲".into());
|
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
|
state
|
||||||
.request_with_timeout("resolve", params, std::time::Duration::from_secs(180))
|
.request_with_timeout("resolve", params, std::time::Duration::from_secs(180))
|
||||||
.await
|
.await
|
||||||
@@ -136,6 +198,7 @@ pub async fn music_download(
|
|||||||
proxy_url: Option<String>,
|
proxy_url: Option<String>,
|
||||||
max_concurrent: Option<u32>,
|
max_concurrent: Option<u32>,
|
||||||
quality: Option<String>,
|
quality: Option<String>,
|
||||||
|
qq_cookie: Option<String>,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
if songs.is_empty() {
|
if songs.is_empty() {
|
||||||
return Err("未选择任何歌曲".into());
|
return Err("未选择任何歌曲".into());
|
||||||
@@ -149,6 +212,7 @@ pub async fn music_download(
|
|||||||
"proxy": proxy_url.unwrap_or_default(),
|
"proxy": proxy_url.unwrap_or_default(),
|
||||||
"maxConcurrent": max_concurrent.unwrap_or(1).clamp(1, 16),
|
"maxConcurrent": max_concurrent.unwrap_or(1).clamp(1, 16),
|
||||||
"quality": quality.unwrap_or_default(),
|
"quality": quality.unwrap_or_default(),
|
||||||
|
"cookies": qq_cookie.unwrap_or_default(),
|
||||||
});
|
});
|
||||||
state
|
state
|
||||||
.request_with_timeout("download", params, std::time::Duration::from_secs(15))
|
.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。
|
// 全部命令返回 serde_json::Value、不加 specta:前端用裸 invoke,映射在 feiniuStore。
|
||||||
|
|
||||||
/// 连接列表 + 激活 id。返回 `{ activeId, list: [{id,name,kind,baseUrl,username,loggedIn,accessCode,insecure}] }`。
|
/// 连接列表 + 激活 id。返回 `{ activeId, list: [{id,name,kind,baseUrl,username,loggedIn,accessCode,insecure}] }`。
|
||||||
|
/// `loggedIn` 的权威是系统凭据管理器里的 token(结构体字段只在凭据库不可用降级时才有值)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
let s = state.load_settings();
|
let s = state.load_settings();
|
||||||
@@ -183,10 +248,11 @@ pub fn feiniu_list_connections(state: State<'_, MusicManager>) -> Result<serde_j
|
|||||||
"kind": c.kind,
|
"kind": c.kind,
|
||||||
"baseUrl": c.base_url,
|
"baseUrl": c.base_url,
|
||||||
"username": c.username,
|
"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,
|
"accessCode": c.access_code,
|
||||||
"insecure": c.insecure,
|
"insecure": c.insecure,
|
||||||
"fnId": c.fn_id,
|
"fnId": c.fn_id,
|
||||||
|
"relay": c.relay,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -205,14 +271,18 @@ pub fn feiniu_save_connection(
|
|||||||
if conn.id.is_empty() {
|
if conn.id.is_empty() {
|
||||||
conn.id = new_conn_id();
|
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) {
|
if let Some(existing) = settings.feiniu_connections.iter_mut().find(|c| c.id == conn.id) {
|
||||||
conn.token = existing.token.clone(); // 保留既有 token
|
conn.token = existing.token.clone(); // 保留既有 token
|
||||||
|
// relay 是解析结果而非用户输入:同为 fnconnect 时沿用,切换类型则重置
|
||||||
|
conn.relay = conn.kind == "fnconnect" && existing.kind == "fnconnect" && existing.relay;
|
||||||
*existing = conn;
|
*existing = conn;
|
||||||
} else {
|
} else {
|
||||||
settings.feiniu_connections.push(conn);
|
settings.feiniu_connections.push(conn);
|
||||||
}
|
}
|
||||||
state.save_settings(&settings)?;
|
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())
|
.map(|c| c.id.clone())
|
||||||
.unwrap_or_default();
|
.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)?;
|
state.save_settings(&settings)?;
|
||||||
|
// 顺带清掉该连接在系统凭据管理器里的 token,避免留下孤儿凭据
|
||||||
|
let _ = crate::music::secrets::secret_delete(&crate::music::secrets::feiniu_token_key(&id));
|
||||||
state.feiniu.sync_with_settings(&settings);
|
state.feiniu.sync_with_settings(&settings);
|
||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
@@ -270,10 +349,12 @@ pub async fn feiniu_login(
|
|||||||
// fnconnect:用 fnId 解析 base_url
|
// fnconnect:用 fnId 解析 base_url
|
||||||
if conn.kind == "fnconnect" {
|
if conn.kind == "fnconnect" {
|
||||||
let fid = extract_fn_id(&conn.fn_id).ok_or_else(|| "FnConnect 连接缺少有效 fnId".to_string())?;
|
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.base_url = url;
|
||||||
|
conn.relay = relay;
|
||||||
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||||
c.base_url = conn.base_url.clone();
|
c.base_url = conn.base_url.clone();
|
||||||
|
c.relay = relay;
|
||||||
}
|
}
|
||||||
state.save_settings(&settings)?;
|
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?;
|
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) {
|
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.device_id = device_id;
|
||||||
existing.username = username;
|
existing.username = username;
|
||||||
}
|
}
|
||||||
@@ -293,16 +378,23 @@ pub async fn feiniu_login(
|
|||||||
state.save_settings(&settings)?;
|
state.save_settings(&settings)?;
|
||||||
state.feiniu.sync_with_settings(&settings);
|
state.feiniu.sync_with_settings(&settings);
|
||||||
let prefix = state.feiniu.media_prefix().await?;
|
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,保留地址/账号),代理 Cookie 同步失效。
|
||||||
|
///
|
||||||
|
/// 顺序有讲究:**必须先删凭据库里的 token 再 sync**,
|
||||||
|
/// 否则 `sync_with_settings` 会从凭据库把刚登出的 token 又读回运行期(看起来「登出无效」)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn feiniu_logout(
|
pub fn feiniu_logout(
|
||||||
state: State<'_, MusicManager>,
|
state: State<'_, MusicManager>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> Result<serde_json::Value, String> {
|
) -> Result<serde_json::Value, String> {
|
||||||
let mut settings = state.load_settings();
|
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) {
|
if let Some(c) = settings.feiniu_connections.iter_mut().find(|c| c.id == connection_id) {
|
||||||
c.token.clear();
|
c.token.clear();
|
||||||
}
|
}
|
||||||
@@ -312,24 +404,37 @@ pub fn feiniu_logout(
|
|||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 测试某连接是否能登录(不持久化 token),探测后恢复原激活连接的运行期状态。
|
/// 测试一条连接**草案**能否登录(不持久化任何变更)。
|
||||||
|
///
|
||||||
|
/// 入参是编辑对话框里的完整草案而非连接 id:新建连接在保存前没有 id,
|
||||||
|
/// 若按 id 查库,对话框里的「测试」在保存前必然报 missing required key。
|
||||||
|
/// 探测以「草案作为唯一连接」装备运行期,结束后恢复持久化的激活连接。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn feiniu_test_connection(
|
pub async fn feiniu_test_connection(
|
||||||
state: State<'_, MusicManager>,
|
state: State<'_, MusicManager>,
|
||||||
connection_id: String,
|
connection: FeiniuConnection,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
) -> Result<serde_json::Value, 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 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();
|
let mut tmp = settings.clone();
|
||||||
|
tmp.feiniu_connections = vec![conn.clone()];
|
||||||
tmp.feiniu_active_id = conn.id.clone();
|
tmp.feiniu_active_id = conn.id.clone();
|
||||||
state.feiniu.sync_with_settings(&tmp);
|
state.feiniu.sync_with_settings(&tmp);
|
||||||
|
|
||||||
let r = state.feiniu.login(&conn.base_url, &username, &password).await;
|
let r = state.feiniu.login(&conn.base_url, &username, &password).await;
|
||||||
// 探测可能污染运行期:恢复为持久化的激活连接
|
// 探测可能污染运行期:恢复为持久化的激活连接
|
||||||
state.feiniu.sync_with_settings(&state.load_settings());
|
state.feiniu.sync_with_settings(&state.load_settings());
|
||||||
@@ -390,9 +495,9 @@ pub async fn feiniu_media_prefix(
|
|||||||
Ok(json!({ "mediaPrefix": 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 s = state.load_settings();
|
||||||
let mut dirs: Vec<String> = vec![s.savedir.clone()];
|
let mut dirs: Vec<String> = vec![s.savedir.clone()];
|
||||||
for d in &s.feiniu_local_dirs {
|
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());
|
dirs.push(d.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(state.feiniu.scan_local(&dirs))
|
dirs.into_iter().filter(|d| !d.trim().is_empty()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 路径是否位于允许的根目录之下。
|
||||||
|
/// 两侧都先 `canonicalize`:`..` 与符号链接因此无法越出根目录。
|
||||||
|
fn is_within_roots(path: &std::path::Path, roots: &[String]) -> bool {
|
||||||
|
let Ok(target) = std::fs::canonicalize(path) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
roots.iter().any(|r| {
|
||||||
|
std::fs::canonicalize(r)
|
||||||
|
.map(|root| target.starts_with(&root))
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出某个下载目录中的音频文件(非递归):`{ items: [{path,name,size,mtim}] }`。
|
||||||
|
///
|
||||||
|
/// 供上传编排在下载目录里定位「刚落盘的文件」——用 `feiniu_scan_local` 会递归遍历
|
||||||
|
/// 整个曲库并解析标签,为一次上传扫全库是纯浪费。不解析标签(匹配只需要文件名与时间)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_list_audio_files(
|
||||||
|
state: State<'_, MusicManager>,
|
||||||
|
dir: String,
|
||||||
|
) -> Result<serde_json::Value, String> {
|
||||||
|
let roots = allowed_local_roots(&state);
|
||||||
|
if roots.is_empty() || !is_within_roots(std::path::Path::new(&dir), &roots) {
|
||||||
|
return Err(format!("拒绝列举曲库目录之外的路径:{dir}"));
|
||||||
|
}
|
||||||
|
tauri::async_runtime::spawn_blocking(move || crate::music::feiniu::list_audio_files(&dir))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("列举目录失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 扫描本地曲库目录中的音频文件:{ items }(目录 = 下载 savedir + 用户自定义 dirs)。
|
||||||
|
/// 标签解析结果缓存在 CacheManager(size+mtim 未变即复用)。
|
||||||
|
///
|
||||||
|
/// **必须 async + spawn_blocking**:Tauri 中不带 async 的命令在主线程执行,
|
||||||
|
/// 而 walkdir 递归 + lofty 全量标签解析在万级曲库下会阻塞数秒——UI 直接冻住。
|
||||||
|
/// 目录与缓存路径都先取出为自有值,避免把 `State` 借进 'static 的阻塞任务。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn feiniu_scan_local(state: State<'_, MusicManager>) -> Result<serde_json::Value, String> {
|
||||||
|
let dirs = allowed_local_roots(&state);
|
||||||
|
let tags_cache = state.feiniu.local_tags_path();
|
||||||
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
|
crate::music::scan_local_dirs(&dirs, Some(tags_cache.as_path()))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("本地曲库扫描任务失败: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 播放缓存状态:{ count, usedBytes, usedMb }。
|
/// 播放缓存状态:{ count, usedBytes, usedMb }。
|
||||||
@@ -478,73 +631,67 @@ pub async fn webdav_delete(
|
|||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ WebDAV 凭据加密存储(Windows 凭据管理器,DPAPI 保护) ============
|
// ============ 敏感串(系统凭据管理器,DPAPI 保护) ============
|
||||||
|
// 统一实现在 crate::music::secrets:WebDAV 账号密码、QQ 音乐 Cookie、飞牛登录 token
|
||||||
/// 凭据以 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}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 读取 WebDAV 凭据。未配置时 username/password 为 null。
|
/// 读取 WebDAV 凭据。未配置时 username/password 为 null。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn webdav_get_secret() -> Result<serde_json::Value, String> {
|
pub fn webdav_get_secret() -> Result<serde_json::Value, String> {
|
||||||
#[cfg(windows)]
|
let parsed = crate::music::secrets::secret_read(crate::music::secrets::KEY_WEBDAV)
|
||||||
{
|
.unwrap_or(None)
|
||||||
Ok(webdav_secret_read()?.unwrap_or_else(|| json!({ "username": null, "password": null })))
|
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok());
|
||||||
}
|
Ok(parsed.unwrap_or_else(|| json!({ "username": null, "password": null })))
|
||||||
#[cfg(not(windows))]
|
|
||||||
{
|
|
||||||
Ok(json!({ "username": null, "password": null }))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。
|
/// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn webdav_save_secret(username: String, password: String) -> Result<serde_json::Value, String> {
|
pub fn webdav_save_secret(username: String, password: String) -> Result<serde_json::Value, String> {
|
||||||
#[cfg(windows)]
|
let blob = json!({ "username": username, "password": password }).to_string();
|
||||||
{
|
crate::music::secrets::secret_write(crate::music::secrets::KEY_WEBDAV, &blob)?;
|
||||||
webdav_secret_write(&username, &password)?;
|
Ok(json!({ "ok": true }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 校验前端可访问的凭据键:只允许白名单内的键(见 `secrets::FRONTEND_KEYS`)。
|
||||||
|
///
|
||||||
|
/// 白名单刻意不含 `webdav-credentials` 与飞牛 token——
|
||||||
|
/// 前端因此无法通过这两个通用命令去读写它们,只能碰自己的 QQ 音乐 Cookie。
|
||||||
|
fn assert_frontend_secret_key(key: &str) -> Result<(), String> {
|
||||||
|
if crate::music::secrets::frontend_key_allowed(key) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("不允许访问的凭据键:{key}"))
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
}
|
||||||
{
|
|
||||||
let _ = (&username, &password);
|
/// 读取一个音乐模块的敏感串(如 QQ 音乐 Cookie):{ value }(未设置 → null)。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn music_secret_get(key: String) -> Result<serde_json::Value, String> {
|
||||||
|
assert_frontend_secret_key(&key)?;
|
||||||
|
Ok(json!({ "value": crate::music::secrets::secret_read(&key)? }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入 / 清除一个音乐模块的敏感串。`value` 为空表示删除该凭据。
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn music_secret_set(key: String, value: String) -> Result<serde_json::Value, String> {
|
||||||
|
assert_frontend_secret_key(&key)?;
|
||||||
|
if value.is_empty() {
|
||||||
|
crate::music::secrets::secret_delete(&key)?;
|
||||||
|
} else {
|
||||||
|
crate::music::secrets::secret_write(&key, &value)?;
|
||||||
}
|
}
|
||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。
|
/// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。
|
||||||
/// 仅允许音频 / 歌词 / 封面扩展名,且拒绝目录——防止前端误删任意文件。
|
/// 仅允许音频 / 歌词 / 封面扩展名,拒绝目录,且**必须落在已配置的下载/曲库目录内**——
|
||||||
|
/// 只校验扩展名的话,前端一旦传错路径就能删掉用户的任意音乐文件。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[specta::specta]
|
#[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] = [
|
const ALLOWED: [&str; 13] = [
|
||||||
"mp3", "flac", "wav", "m4a", "aac", "ogg", "ape", "wma", "lrc", "jpg", "jpeg", "png", "webp",
|
"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() {
|
if meta.is_dir() {
|
||||||
return Err("拒绝删除目录".to_string());
|
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}"))?;
|
std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))?;
|
||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ impl CacheManager {
|
|||||||
Self { root, index_path }
|
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> {
|
fn load_index(&self) -> HashMap<String, CacheEntry> {
|
||||||
fs::read_to_string(&self.index_path)
|
fs::read_to_string(&self.index_path)
|
||||||
.ok()
|
.ok()
|
||||||
|
|||||||
@@ -1,51 +1,86 @@
|
|||||||
//! FnConnect 远程连接解析(参考 feiniu-car-music `fn-api.js`)。
|
//! FnConnect 远程连接解析。
|
||||||
//!
|
//!
|
||||||
//! fnId → 网关 `https://5ddd.com/api/v1/fn/con`(authx md5 签名)→ 内网/公网/中继候选 →
|
//! 链路参考第三方客户端 FnMusic 的 `fn_connection_probe_service.dart`:
|
||||||
//! 探测可达性 → 得到可用的 base_url(含 mode=relay 的中继地址)。
|
//! 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 md5::{Digest, Md5};
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
/// 网关地址与签名常量(对齐 feiniu-car-music)。
|
/// 网关主机(按序回退;5ddd.com 与 fnos.net 为同一服务的不同集群入口)。
|
||||||
const FN_CONNECT_URL: &str = "https://5ddd.com/api/v1/fn/con";
|
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_AUTHX_PREFIX: &str = "NDzZTVxnRKP8Z0jXg1VAMonaG8akvh";
|
||||||
const FN_API_KEY: &str = "zIGtkc3dqZnJpd29qZXJqa2w7c";
|
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 {
|
fn md5_hex(input: &str) -> String {
|
||||||
let mut h = Md5::new();
|
let mut h = Md5::new();
|
||||||
h.update(input.as_bytes());
|
h.update(input.as_bytes());
|
||||||
format!("{:x}", h.finalize())
|
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> {
|
pub fn extract_fn_id(input: &str) -> Option<String> {
|
||||||
let s = input.trim();
|
let s = input.trim();
|
||||||
if s.is_empty() {
|
if s.is_empty() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some(id) = s.split_once("fnos.net/").map(|(_, r)| r.split('/').next().unwrap_or("")) {
|
// 去协议后统一按「可能带路径的 host」处理
|
||||||
if !id.is_empty() {
|
let mut t = s;
|
||||||
return Some(id.trim().to_string());
|
for p in ["https://", "http://"] {
|
||||||
|
if let Some(r) = t.strip_prefix(p) {
|
||||||
|
t = r;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(rest) = s.rsplit_once("/") {
|
let t = t.trim_end_matches('/');
|
||||||
let last = rest.1;
|
// 形如 <网关>/<id>
|
||||||
if last.ends_with(".5ddd.com") {
|
for gw in ["fnos.net/", "5ddd.com/"] {
|
||||||
return Some(last.trim_end_matches(".5ddd.com").to_string());
|
if let Some((_, rest)) = t.split_once(gw) {
|
||||||
|
let id = rest.split('/').next().unwrap_or("").trim();
|
||||||
|
if !id.is_empty() {
|
||||||
|
return Some(id.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if s.ends_with(".5ddd.com") {
|
// 形如 <id>.<网关>
|
||||||
return Some(s.trim_end_matches(".5ddd.com").to_string());
|
for suffix in [".fnos.net", ".5ddd.com"] {
|
||||||
|
if let Some(id) = t.strip_suffix(suffix) {
|
||||||
|
if !id.is_empty() {
|
||||||
|
return Some(id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// 裸 fnId
|
// 裸 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());
|
return Some(s.to_string());
|
||||||
}
|
}
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 计算网关 authx 签名。
|
/// 计算网关 authx 签名。
|
||||||
|
///
|
||||||
|
/// 原文(下划线连接,**注意 md5 与 API_KEY 之间只有一个下划线**):
|
||||||
|
/// `PREFIX_url_nonce_timestamp_md5(body)_API_KEY` → 取 md5 作为 sign。
|
||||||
fn fn_authx(method: &str, url: &str, data: &Value) -> String {
|
fn fn_authx(method: &str, url: &str, data: &Value) -> String {
|
||||||
let body = if method.eq_ignore_ascii_case("get") {
|
let body = if method.eq_ignore_ascii_case("get") {
|
||||||
String::new()
|
String::new()
|
||||||
@@ -62,93 +97,199 @@ fn fn_authx(method: &str, url: &str, data: &Value) -> String {
|
|||||||
.map(|d| d.as_millis().to_string())
|
.map(|d| d.as_millis().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let raw = format!(
|
let raw = format!(
|
||||||
"{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}__{FN_API_KEY}",
|
"{FN_AUTHX_PREFIX}_{url}_{nonce}_{timestamp}_{}_{FN_API_KEY}",
|
||||||
md5_hex(&body)
|
md5_hex(&body)
|
||||||
);
|
);
|
||||||
format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw))
|
format!("nonce={nonce}×tamp={timestamp}&sign={}", md5_hex(&raw))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从网关查询 fnId 的连接参数。
|
/// 从网关查询 fnId 的连接参数(多网关按序回退)。
|
||||||
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
|
pub async fn query_fn_connect(fn_id: &str) -> Result<Value, String> {
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(10))
|
.timeout(FN_QUERY_TIMEOUT)
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let body = json!({ "fnId": fn_id });
|
let body = json!({ "fnId": fn_id });
|
||||||
let resp = client
|
let authx = fn_authx("post", FN_CON_PATH, &body);
|
||||||
.post(FN_CONNECT_URL)
|
|
||||||
.header("Content-Type", "application/json")
|
let mut last_err = String::from("FnConnect 网关不可达");
|
||||||
.header("authx", fn_authx("post", "/api/v1/fn/con", &body))
|
for host in FN_CONNECT_HOSTS {
|
||||||
.json(&body)
|
let resp = match client
|
||||||
.send()
|
.post(format!("{host}{FN_CON_PATH}"))
|
||||||
.await
|
.header("Content-Type", "application/json")
|
||||||
.map_err(|e| format!("FnConnect 网关不可达: {e}"))?;
|
.header("authx", authx.clone())
|
||||||
let b: Value = resp.json().await.map_err(|e| e.to_string())?;
|
.json(&body)
|
||||||
if b["code"].as_i64().unwrap_or(-1) != 0 {
|
.send()
|
||||||
return Err(b["msg"].as_str().unwrap_or("FnConnect 网关返回错误").to_string());
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
last_err = format!("FnConnect 网关不可达: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let b: Value = match resp.json().await {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
last_err = format!("FnConnect 网关响应异常: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if b["code"].as_i64().unwrap_or(-1) != 0 {
|
||||||
|
last_err = b["msg"]
|
||||||
|
.as_str()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "FnConnect 网关返回错误".to_string());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return Ok(b["data"].clone());
|
||||||
}
|
}
|
||||||
Ok(b["data"].clone())
|
Err(last_err)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 构建候选 base_url 列表。返回 (url, is_relay)。
|
/// 去掉 `host:port` 形式的端口(仅用于中继域名,中继恒走 443)。
|
||||||
pub fn build_candidates(data: &Value) -> Vec<(String, bool)> {
|
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 mut out: Vec<(String, bool)> = Vec::new();
|
||||||
let port = &data["port"];
|
let port = &data["port"];
|
||||||
let http = port["httpPort"].as_u64().unwrap_or(5666);
|
let http = port["httpPort"].as_u64().unwrap_or(5666);
|
||||||
let https = port["httpsPort"].as_u64().unwrap_or(5667);
|
let https = port["httpsPort"].as_u64().unwrap_or(5667);
|
||||||
let empty = vec![];
|
let empty: Vec<Value> = Vec::new();
|
||||||
for ip in data["ipv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
|
||||||
out.push((format!("http://{ip}:{http}"), false));
|
let strings = |key: &str| -> Vec<String> {
|
||||||
out.push((format!("https://{ip}:{https}"), false));
|
data[key]
|
||||||
}
|
.as_array()
|
||||||
for ip in data["publicIpv4"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
.unwrap_or(&empty)
|
||||||
out.push((format!("http://{ip}:{http}"), false));
|
|
||||||
out.push((format!("https://{ip}:{https}"), false));
|
|
||||||
}
|
|
||||||
for ip in data["publicIpv6"].as_array().unwrap_or(&empty).iter().filter_map(|v| v.as_str()) {
|
|
||||||
out.push((format!("http://[{ip}]:{http}"), false));
|
|
||||||
out.push((format!("https://[{ip}]:{https}"), false));
|
|
||||||
}
|
|
||||||
let relays = data["fn"].as_array().unwrap_or(&empty);
|
|
||||||
let relay_addrs: Vec<String> = if relays.is_empty() {
|
|
||||||
vec!["5ddd.com".to_string()]
|
|
||||||
} else {
|
|
||||||
relays
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
for addr in relay_addrs {
|
|
||||||
let domain = addr.split(':').next().unwrap_or(&addr).to_string();
|
// 1) 内网 IPv4
|
||||||
out.push((format!("https://{domain}"), true));
|
for ip in strings("ipv4") {
|
||||||
|
out.push((format!("http://{ip}:{http}"), false));
|
||||||
|
out.push((format!("https://{ip}:{https}"), false));
|
||||||
|
}
|
||||||
|
// 2) 公网 IPv6(NAS 侧可禁用)
|
||||||
|
if !data["forbbidPublicIpv6"].as_bool().unwrap_or(false) {
|
||||||
|
for ip in strings("publicIpv6") {
|
||||||
|
out.push((format!("http://[{ip}]:{http}"), false));
|
||||||
|
out.push((format!("https://[{ip}]:{https}"), false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3) 公网 IPv4
|
||||||
|
for ip in strings("publicIpv4") {
|
||||||
|
out.push((format!("http://{ip}:{http}"), false));
|
||||||
|
out.push((format!("https://{ip}:{https}"), false));
|
||||||
|
}
|
||||||
|
// 4) 中继:仅 HTTPS;网关未返回时按两种集群域名兜底
|
||||||
|
let mut relays = strings("fn");
|
||||||
|
if relays.is_empty() {
|
||||||
|
relays = vec![
|
||||||
|
format!("{fn_id}.fnos.net"),
|
||||||
|
format!("{fn_id}.5ddd.com"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
for addr in relays {
|
||||||
|
let domain = strip_port(&addr);
|
||||||
|
if !domain.is_empty() {
|
||||||
|
out.push((format!("https://{domain}"), true));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 探测某个 base_url 是否可用。
|
/// 探测单个 base_url 是否可用(中继候选携带 `mode=relay` 才会被网关转发)。
|
||||||
async fn probe(url: &str) -> bool {
|
///
|
||||||
let client = reqwest::Client::builder()
|
/// 判据必须排除 3xx:重定向说明请求**没有真正落到 NAS 音乐后端**
|
||||||
.timeout(std::time::Duration::from_secs(6))
|
/// (中继缺 `mode=relay` 时网关 302 回登录页;端口上实际是 fnOS Web UI 时同样 302)。
|
||||||
.build()
|
/// 早先把「任何 < 500」都当可达,结果候选探测通过、紧接着登录报 `HTTP 302 Found`。
|
||||||
.unwrap_or_else(|_| reqwest::Client::new());
|
async fn probe(client: &reqwest::Client, url: &str, relay: bool) -> bool {
|
||||||
let full = format!("{}/music/api/v1/track/list?page=1&size=1", url.trim_end_matches('/'));
|
let timeout = if relay {
|
||||||
match client.get(&full).send().await {
|
FN_PROBE_TIMEOUT_RELAY
|
||||||
Ok(resp) => resp.status().as_u16() < 500,
|
} 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,
|
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> {
|
pub async fn resolve_base_url(fn_id: &str) -> Result<(String, bool), String> {
|
||||||
let data = query_fn_connect(fn_id).await?;
|
let data = query_fn_connect(fn_id).await?;
|
||||||
let candidates = build_candidates(&data);
|
let candidates = build_candidates(&data, fn_id);
|
||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Err("FnConnect 未返回可用地址".into());
|
return Err("FnConnect 未返回可用地址".into());
|
||||||
}
|
}
|
||||||
for (url, relay) in &candidates {
|
|
||||||
if probe(url).await {
|
let client = reqwest::Client::builder()
|
||||||
return Ok((url.clone(), *relay));
|
.timeout(FN_PROBE_TIMEOUT_RELAY)
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let mut pending = FuturesUnordered::new();
|
||||||
|
for (i, (url, relay)) in candidates.iter().enumerate() {
|
||||||
|
let client = client.clone();
|
||||||
|
let url = url.clone();
|
||||||
|
let relay = *relay;
|
||||||
|
pending.push(async move {
|
||||||
|
let ok = probe(&client, &url, relay).await;
|
||||||
|
(i, url, relay, ok)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut undecided: Vec<usize> = (0..candidates.len()).collect();
|
||||||
|
let mut best: Option<(usize, String, bool)> = None;
|
||||||
|
|
||||||
|
while let Some((i, url, relay, ok)) = pending.next().await {
|
||||||
|
undecided.retain(|x| *x != i);
|
||||||
|
if ok && best.as_ref().map_or(true, |(bi, _, _)| i < *bi) {
|
||||||
|
best = Some((i, url, relay));
|
||||||
|
}
|
||||||
|
// 早停:已有可达候选,且不存在索引更小(优先级更高)的未决候选
|
||||||
|
if let Some((bi, _, _)) = best.as_ref() {
|
||||||
|
if !undecided.iter().any(|x| x < bi) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(format!("FnConnect 候选均不可达({} 个)", candidates.len()))
|
|
||||||
|
match best {
|
||||||
|
Some((idx, url, relay)) => {
|
||||||
|
let _ = idx;
|
||||||
|
Ok((url, relay))
|
||||||
|
}
|
||||||
|
None => Err(format!("FnConnect 候选均不可达({} 个)", candidates.len())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@
|
|||||||
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
//! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。
|
||||||
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
//! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。
|
||||||
|
|
||||||
use std::path::Path;
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::http::StatusCode;
|
use axum::http::StatusCode;
|
||||||
|
use lofty::file::{AudioFile, TaggedFileExt};
|
||||||
|
use lofty::tag::Accessor;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
@@ -48,6 +51,10 @@ pub struct FeiniuConnection {
|
|||||||
/// fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id)
|
/// fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub fn_id: String,
|
pub fn_id: String,
|
||||||
|
/// 是否经由 FnConnect 中继链路(`<fnId>.fnos.net`)。
|
||||||
|
/// 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。
|
||||||
|
#[serde(default)]
|
||||||
|
pub relay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FeiniuConnection {
|
impl Default for FeiniuConnection {
|
||||||
@@ -63,6 +70,7 @@ impl Default for FeiniuConnection {
|
|||||||
access_code: String::new(),
|
access_code: String::new(),
|
||||||
insecure: false,
|
insecure: false,
|
||||||
fn_id: String::new(),
|
fn_id: String::new(),
|
||||||
|
relay: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +83,8 @@ struct Conn {
|
|||||||
device_id: String,
|
device_id: String,
|
||||||
access_code: String,
|
access_code: String,
|
||||||
insecure: bool,
|
insecure: bool,
|
||||||
|
/// FnConnect 中继链路标记
|
||||||
|
relay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
/// LAN 直连,`no_proxy` 避免被代理模块(mihomo)拦走;按 insecure 惰性重建(支持自签证书)。
|
||||||
@@ -83,8 +93,26 @@ struct ClientSlot {
|
|||||||
client: reqwest::Client,
|
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 {
|
pub struct Feiniu {
|
||||||
client: Mutex<Option<ClientSlot>>,
|
client: Mutex<Option<ClientSlot>>,
|
||||||
|
/// 流代理专用 client(无总超时)
|
||||||
|
stream_client: Mutex<Option<ClientSlot>>,
|
||||||
conn: Mutex<Conn>,
|
conn: Mutex<Conn>,
|
||||||
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
proxy: Mutex<Option<(u16, ProxyShared)>>,
|
||||||
cache: CacheManager,
|
cache: CacheManager,
|
||||||
@@ -94,6 +122,7 @@ impl Default for Feiniu {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
client: Mutex::new(None),
|
client: Mutex::new(None),
|
||||||
|
stream_client: Mutex::new(None),
|
||||||
conn: Mutex::new(Conn {
|
conn: Mutex::new(Conn {
|
||||||
base_url: String::new(),
|
base_url: String::new(),
|
||||||
token: String::new(),
|
token: String::new(),
|
||||||
@@ -101,6 +130,7 @@ impl Default for Feiniu {
|
|||||||
device_id: String::new(),
|
device_id: String::new(),
|
||||||
access_code: String::new(),
|
access_code: String::new(),
|
||||||
insecure: false,
|
insecure: false,
|
||||||
|
relay: false,
|
||||||
}),
|
}),
|
||||||
proxy: Mutex::new(None),
|
proxy: Mutex::new(None),
|
||||||
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
|
cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建
|
||||||
@@ -108,40 +138,56 @@ impl Default for Feiniu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 按 insecure 惰性构建 client(`streaming` 决定是否免总超时)。
|
||||||
|
fn slot_client(slot: &Mutex<Option<ClientSlot>>, insecure: bool, streaming: bool) -> reqwest::Client {
|
||||||
|
let mut g = slot.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
|
let hit = g.as_ref().map(|s| s.insecure == insecure).unwrap_or(false);
|
||||||
|
if !hit {
|
||||||
|
*g = Some(ClientSlot {
|
||||||
|
insecure,
|
||||||
|
client: build_client(insecure, streaming),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
g.as_ref().unwrap().client.clone()
|
||||||
|
}
|
||||||
|
|
||||||
impl Feiniu {
|
impl Feiniu {
|
||||||
/// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。
|
/// 设置缓存根目录({app_data}/music/cache),应用启动时调用一次。
|
||||||
pub fn set_cache_root(&mut self, app_data_dir: &Path) {
|
pub fn set_cache_root(&mut self, app_data_dir: &Path) {
|
||||||
self.cache = CacheManager::new(app_data_dir);
|
self.cache = CacheManager::new(app_data_dir);
|
||||||
}
|
}
|
||||||
/// 取(并惰性构建)对应 insecure 的 reqwest client。
|
/// 本地曲库标签缓存文件路径({cache_root}/local-tags.json)。
|
||||||
|
pub fn local_tags_path(&self) -> PathBuf {
|
||||||
|
self.cache.local_tags_path()
|
||||||
|
}
|
||||||
|
/// 取(并惰性构建)对应 insecure 的 reqwest client(普通请求,10s 总超时)。
|
||||||
fn client(&self, insecure: bool) -> reqwest::Client {
|
fn client(&self, insecure: bool) -> reqwest::Client {
|
||||||
let mut g = self.client.lock().unwrap_or_else(|e| e.into_inner());
|
slot_client(&self.client, insecure, false)
|
||||||
let hit = g.as_ref().map(|s| s.insecure == insecure).unwrap_or(false);
|
}
|
||||||
if !hit {
|
|
||||||
*g = Some(ClientSlot {
|
/// 取流式请求专用 client(无总超时)。
|
||||||
insecure,
|
fn stream_client(&self, insecure: bool) -> reqwest::Client {
|
||||||
client: reqwest::Client::builder()
|
slot_client(&self.stream_client, insecure, true)
|
||||||
.no_proxy()
|
|
||||||
.timeout(Duration::from_secs(10))
|
|
||||||
.danger_accept_invalid_certs(insecure)
|
|
||||||
.build()
|
|
||||||
.unwrap_or_else(|_| reqwest::Client::new()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
g.as_ref().unwrap().client.clone()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。
|
/// 从持久化设置刷新运行期连接与代理配置(以激活连接为准;幂等)。
|
||||||
pub fn sync_with_settings(&self, s: &MusicSettings) {
|
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() {
|
match s.feiniu_active() {
|
||||||
Some(c) => (
|
Some(c) => (
|
||||||
c.base_url.clone(),
|
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.username.clone(),
|
||||||
c.device_id.clone(),
|
c.device_id.clone(),
|
||||||
c.access_code.clone(),
|
c.access_code.clone(),
|
||||||
c.insecure,
|
c.insecure,
|
||||||
|
c.relay,
|
||||||
),
|
),
|
||||||
None => (
|
None => (
|
||||||
String::new(),
|
String::new(),
|
||||||
@@ -150,6 +196,7 @@ impl Feiniu {
|
|||||||
String::new(),
|
String::new(),
|
||||||
String::new(),
|
String::new(),
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
if let Ok(mut c) = self.conn.lock() {
|
if let Ok(mut c) = self.conn.lock() {
|
||||||
@@ -159,6 +206,7 @@ impl Feiniu {
|
|||||||
c.device_id = device_id;
|
c.device_id = device_id;
|
||||||
c.access_code = access_code;
|
c.access_code = access_code;
|
||||||
c.insecure = insecure;
|
c.insecure = insecure;
|
||||||
|
c.relay = relay;
|
||||||
}
|
}
|
||||||
// 确保 client 构建到位(insecure 变化时重建)
|
// 确保 client 构建到位(insecure 变化时重建)
|
||||||
self.client(insecure);
|
self.client(insecure);
|
||||||
@@ -178,7 +226,8 @@ impl Feiniu {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sync_proxy_client(&self) {
|
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 Ok(mut g) = self.proxy.lock() {
|
||||||
if let Some((_, shared)) = g.as_mut() {
|
if let Some((_, shared)) = g.as_mut() {
|
||||||
shared.client = client;
|
shared.client = client;
|
||||||
@@ -191,18 +240,44 @@ impl Feiniu {
|
|||||||
(insecure, self.client(insecure))
|
(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 {
|
fn current_cfg(&self) -> ProxyCfg {
|
||||||
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
let c = self.conn.lock().unwrap_or_else(|e| e.into_inner());
|
||||||
ProxyCfg {
|
ProxyCfg {
|
||||||
base_url: c.base_url.clone(),
|
base_url: c.base_url.clone(),
|
||||||
token: c.token.clone(),
|
token: c.token.clone(),
|
||||||
access_code: c.access_code.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());
|
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 执行登录(探测/登录连接共用)。
|
/// 对某个 base_url 执行登录(探测/登录连接共用)。
|
||||||
@@ -228,10 +303,20 @@ impl Feiniu {
|
|||||||
"password": sha256_hex(password),
|
"password": sha256_hex(password),
|
||||||
"deviceId": device_id,
|
"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)
|
.client(insecure)
|
||||||
.post(format!("{base}/music/api/v1/user/password-login"))
|
.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()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -321,53 +406,6 @@ impl Feiniu {
|
|||||||
Ok(format!("http://127.0.0.1:{port}/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 {
|
pub fn cache_status(&self) -> Value {
|
||||||
@@ -385,13 +423,16 @@ impl Feiniu {
|
|||||||
if let Some(hit) = self.cache.hit(guid) {
|
if let Some(hit) = self.cache.hit(guid) {
|
||||||
return Ok(Some(hit));
|
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() {
|
if base.is_empty() || token.is_empty() {
|
||||||
return Err("未登录".into());
|
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}"));
|
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() {
|
if !access_code.is_empty() {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
rb = rb
|
rb = rb
|
||||||
@@ -439,7 +480,8 @@ impl Feiniu {
|
|||||||
return Ok(*port);
|
return Ok(*port);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let (_, client) = self.conn_client();
|
// 流代理用无总超时的 client(见 build_client)
|
||||||
|
let (_, client) = self.conn_stream_client();
|
||||||
let shared = ProxyShared {
|
let shared = ProxyShared {
|
||||||
client,
|
client,
|
||||||
cfg: Arc::new(Mutex::new(self.current_cfg())),
|
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> {
|
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() {
|
if base.is_empty() || token.is_empty() {
|
||||||
return Err("未登录".into());
|
return Err("未登录".into());
|
||||||
}
|
}
|
||||||
let (_, client) = self.conn_client();
|
let (_, client) = self.conn_client();
|
||||||
let qrefs: Vec<(&str, &str)> = query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
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);
|
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() {
|
if !access_code.is_empty() {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
rb = rb
|
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 响应中取第一段歌词文本(响应结构未文档化,做宽松映射)。
|
/// 尽力从 /lyric/list 响应中取第一段歌词文本(响应结构未文档化,做宽松映射)。
|
||||||
fn extract_lyric_text(v: &Value) -> String {
|
fn extract_lyric_text(v: &Value) -> String {
|
||||||
match v {
|
match v {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ use axum::{
|
|||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use lofty::file::TaggedFileExt;
|
||||||
|
|
||||||
use super::conn::normalize_base_url;
|
use super::conn::normalize_base_url;
|
||||||
|
|
||||||
/// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。
|
/// 由 Feiniu 运行期与代理 handler 共享的连接配置(登录更新、登出置空)。
|
||||||
@@ -23,6 +25,8 @@ pub struct ProxyCfg {
|
|||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
pub token: String,
|
pub token: String,
|
||||||
pub access_code: String,
|
pub access_code: String,
|
||||||
|
/// FnConnect 中继链路:所有请求需携带 `Cookie: mode=relay`
|
||||||
|
pub relay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -44,6 +48,7 @@ pub async fn start(shared: ProxyShared) -> Result<u16, String> {
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/feiniu/stream", get(proxy_stream))
|
.route("/feiniu/stream", get(proxy_stream))
|
||||||
.route("/feiniu/cover", get(proxy_cover))
|
.route("/feiniu/cover", get(proxy_cover))
|
||||||
|
.route("/feiniu/local-cover", get(proxy_local_cover))
|
||||||
.with_state(shared);
|
.with_state(shared);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = axum::serve(listener, app).await {
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
@@ -53,6 +58,48 @@ pub async fn start(shared: ProxyShared) -> Result<u16, String> {
|
|||||||
Ok(port)
|
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(
|
async fn proxy_stream(
|
||||||
State(s): State<ProxyShared>,
|
State(s): State<ProxyShared>,
|
||||||
Query(q): Query<HashMap<String, String>>,
|
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 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);
|
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() {
|
if !cfg.access_code.is_empty() {
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
rb = rb
|
rb = rb
|
||||||
@@ -143,6 +198,12 @@ async fn forward(
|
|||||||
let cl = resp.headers().get(header::CONTENT_LENGTH).cloned();
|
let cl = resp.headers().get(header::CONTENT_LENGTH).cloned();
|
||||||
let cr = resp.headers().get(header::CONTENT_RANGE).cloned();
|
let cr = resp.headers().get(header::CONTENT_RANGE).cloned();
|
||||||
let ar = resp.headers().get(header::ACCEPT_RANGES).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 body = axum::body::Body::from_stream(resp.bytes_stream());
|
||||||
let mut out = Response::new(body);
|
let mut out = Response::new(body);
|
||||||
@@ -160,5 +221,17 @@ async fn forward(
|
|||||||
if let Some(v) = ar {
|
if let Some(v) = ar {
|
||||||
h.insert(header::ACCEPT_RANGES, v);
|
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
|
out
|
||||||
}
|
}
|
||||||
+114
-26
@@ -19,18 +19,23 @@ mod bridge;
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod feiniu;
|
mod feiniu;
|
||||||
mod runtime;
|
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::{
|
pub use commands::{
|
||||||
feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status,
|
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_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,
|
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_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_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,
|
music_resolve, music_save_settings, music_search, music_secret_get, music_secret_set,
|
||||||
webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload,
|
music_stop_bridge, music_update_musicdl, webdav_delete, webdav_get_secret,
|
||||||
|
webdav_save_secret, webdav_test, webdav_upload,
|
||||||
};
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -84,6 +89,11 @@ pub struct MusicEnvStatus {
|
|||||||
pub musicdl_installed: bool,
|
pub musicdl_installed: bool,
|
||||||
/// musicdl 版本
|
/// musicdl 版本
|
||||||
pub musicdl_version: Option<String>,
|
pub musicdl_version: Option<String>,
|
||||||
|
/// 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准
|
||||||
|
pub musicdl_expected: String,
|
||||||
|
/// 已装 musicdl 是否与锁定版本不一致。
|
||||||
|
/// 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。
|
||||||
|
pub musicdl_outdated: bool,
|
||||||
/// FFmpeg 是否可用(部分音源需要,非必需)
|
/// FFmpeg 是否可用(部分音源需要,非必需)
|
||||||
pub ffmpeg: Option<String>,
|
pub ffmpeg: Option<String>,
|
||||||
/// 桥接进程是否在运行
|
/// 桥接进程是否在运行
|
||||||
@@ -125,6 +135,10 @@ pub struct MusicSettings {
|
|||||||
pub select_quality_on_download: bool,
|
pub select_quality_on_download: bool,
|
||||||
/// 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K")
|
/// 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K")
|
||||||
pub default_download_quality: String,
|
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,空=未配置)
|
/// 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feiniu_base_url: String,
|
pub feiniu_base_url: String,
|
||||||
@@ -140,7 +154,7 @@ pub struct MusicSettings {
|
|||||||
/// 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空)
|
/// 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feiniu_access_code: String,
|
pub feiniu_access_code: String,
|
||||||
/// 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect)
|
/// 飞牛音乐连接列表(多连接:局域网 / FnConnect)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feiniu_connections: Vec<FeiniuConnection>,
|
pub feiniu_connections: Vec<FeiniuConnection>,
|
||||||
/// 当前激活连接的 id
|
/// 当前激活连接的 id
|
||||||
@@ -175,32 +189,84 @@ impl MusicSettings {
|
|||||||
.or_else(|| self.feiniu_connections.first())
|
.or_else(|| self.feiniu_connections.first())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 兼容旧版单连接字段:若连接列表为空且存在旧 feiniu_* 字段,则迁移为一条默认连接。
|
/// 兼容旧版单连接字段:把旧字段迁移成列表里的一条连接,并**清除旧字段**。
|
||||||
pub fn migrate_feiniu(&mut self) {
|
///
|
||||||
if self.feiniu_connections.is_empty() {
|
/// 必须是「一次性」的:旧字段一旦残留,用户把连接删光后,下一次 `load_settings()`
|
||||||
if !self.feiniu_base_url.trim().is_empty() {
|
/// 会再次命中「列表为空 + 旧字段非空」而重新造出一条连接,
|
||||||
let base = self.feiniu_base_url.clone();
|
/// 表现为「删掉的连接切个页又回来了」。因此迁移完成后要清空旧字段,
|
||||||
self.feiniu_connections.push(FeiniuConnection {
|
/// 且返回是否发生变更,由 `load_settings` 负责落盘。
|
||||||
id: "default".to_string(),
|
pub fn migrate_feiniu(&mut self) -> bool {
|
||||||
name: base.clone(),
|
let mut changed = false;
|
||||||
kind: "lan".to_string(),
|
|
||||||
base_url: base,
|
// 1) 旧单连接字段 → 连接列表(仅在列表为空时迁移)
|
||||||
username: self.feiniu_username.clone(),
|
if self.feiniu_connections.is_empty() && !self.feiniu_base_url.trim().is_empty() {
|
||||||
token: self.feiniu_token.clone(),
|
let base = self.feiniu_base_url.clone();
|
||||||
device_id: self.feiniu_device_id.clone(),
|
self.feiniu_connections.push(FeiniuConnection {
|
||||||
access_code: self.feiniu_access_code.clone(),
|
id: "default".to_string(),
|
||||||
insecure: false,
|
name: base.clone(),
|
||||||
fn_id: String::new(),
|
kind: "lan".to_string(),
|
||||||
});
|
base_url: base,
|
||||||
self.feiniu_active_id = "default".to_string();
|
username: self.feiniu_username.clone(),
|
||||||
|
token: self.feiniu_token.clone(),
|
||||||
|
device_id: self.feiniu_device_id.clone(),
|
||||||
|
access_code: self.feiniu_access_code.clone(),
|
||||||
|
insecure: false,
|
||||||
|
fn_id: String::new(),
|
||||||
|
relay: false,
|
||||||
|
});
|
||||||
|
self.feiniu_active_id = "default".to_string();
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) 无论列表是否为空,旧字段都已无意义(内容已并入连接,或本就没有连接),
|
||||||
|
// 一律清空并落盘,杜绝其再次触发迁移。
|
||||||
|
for v in [
|
||||||
|
&mut self.feiniu_base_url,
|
||||||
|
&mut self.feiniu_token,
|
||||||
|
&mut self.feiniu_username,
|
||||||
|
&mut self.feiniu_device_id,
|
||||||
|
&mut self.feiniu_access_code,
|
||||||
|
] {
|
||||||
|
if !v.is_empty() {
|
||||||
|
v.clear();
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
} else if self.feiniu_active_id.is_empty()
|
}
|
||||||
|
|
||||||
|
// 3) 激活 id 兜底(内存态修正,无需落盘)
|
||||||
|
if self.feiniu_active_id.is_empty()
|
||||||
|| !self.feiniu_connections.iter().any(|c| c.id == self.feiniu_active_id)
|
|| !self.feiniu_connections.iter().any(|c| c.id == self.feiniu_active_id)
|
||||||
{
|
{
|
||||||
if let Some(c) = self.feiniu_connections.first() {
|
if let Some(c) = self.feiniu_connections.first() {
|
||||||
self.feiniu_active_id = c.id.clone();
|
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(),
|
download_engine: "musicdl".to_string(),
|
||||||
select_quality_on_download: false,
|
select_quality_on_download: false,
|
||||||
default_download_quality: "最高".to_string(), // 默认下载最高音质
|
default_download_quality: "最高".to_string(), // 默认下载最高音质
|
||||||
|
qq_cookie: String::new(),
|
||||||
feiniu_base_url: String::new(),
|
feiniu_base_url: String::new(),
|
||||||
feiniu_token: String::new(),
|
feiniu_token: String::new(),
|
||||||
feiniu_username: String::new(),
|
feiniu_username: String::new(),
|
||||||
@@ -338,14 +405,22 @@ impl MusicManager {
|
|||||||
if settings.sources.is_empty() {
|
if settings.sources.is_empty() {
|
||||||
settings.sources = defaults.sources;
|
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() {
|
if let Ok(mut cache) = self.settings_cache.lock() {
|
||||||
*cache = Some(SettingsCacheEntry {
|
*cache = Some(SettingsCacheEntry {
|
||||||
read_at: Instant::now(),
|
read_at: Instant::now(),
|
||||||
settings: settings.clone(),
|
settings: settings.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if migrated || secrets_migrated {
|
||||||
|
if let Err(e) = self.save_settings(&settings) {
|
||||||
|
crate::logger::log_error("music", &format!("迁移飞牛连接设置落盘失败: {e}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
settings
|
settings
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,7 +487,10 @@ impl MusicManager {
|
|||||||
python_source: probe.python_source.to_string(),
|
python_source: probe.python_source.to_string(),
|
||||||
bundled_python: probe.bundled_python,
|
bundled_python: probe.bundled_python,
|
||||||
musicdl_installed: probe.musicdl_installed,
|
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_version: probe.musicdl_version,
|
||||||
|
musicdl_expected: MUSICDL_VERSION.to_string(),
|
||||||
ffmpeg: probe.ffmpeg,
|
ffmpeg: probe.ffmpeg,
|
||||||
bridge_running,
|
bridge_running,
|
||||||
runtime_dir,
|
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(同步子进程调用,仅在设置页触发)
|
/// 检查指定 Python 能否导入 musicdl(同步子进程调用,仅在设置页触发)
|
||||||
fn check_musicdl(exe: &PathBuf) -> (bool, Option<String>) {
|
fn check_musicdl(exe: &PathBuf) -> (bool, Option<String>) {
|
||||||
let mut cmd = std::process::Command::new(exe);
|
let mut cmd = std::process::Command::new(exe);
|
||||||
|
|||||||
@@ -158,6 +158,86 @@ impl MusicManager {
|
|||||||
ok
|
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(
|
async fn emit_progress(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//! 敏感串的统一存放处(Windows 系统凭据管理器,DPAPI 保护)。
|
||||||
|
//!
|
||||||
|
//! 存在的理由:本项目里出现过**两种安全姿态**——WebDAV 账号密码走凭据管理器,
|
||||||
|
//! 而飞牛登录 token 与 QQ 音乐 Cookie 明文躺在 `settings.json` / localStorage。
|
||||||
|
//! 同样是可冒充身份的凭据,不该区别对待。
|
||||||
|
//!
|
||||||
|
//! 约定:
|
||||||
|
//! - 一律使用 `Thing` 作为凭据服务名,`key` 作为用户名(Entry 的 account)。
|
||||||
|
//! - 明文只允许存在于内存与系统凭据库,禁止回写 `settings.json` / localStorage。
|
||||||
|
//! - 迁移采用「先写凭据库成功、再清明文」的顺序;**写失败时保留明文**,
|
||||||
|
//! 宁可牺牲一致性也不能把用户已登录的会话弄丢。
|
||||||
|
//! - 非 Windows 平台没有凭据管理器:读取返回 None、写入报错,
|
||||||
|
//! 调用方据此退化为「明文存 settings」(功能优先)。
|
||||||
|
|
||||||
|
use crate::logger;
|
||||||
|
|
||||||
|
/// 凭据服务名(与历史实现一致,改动会导致已有 WebDAV 凭据读不到)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
const SERVICE: &str = "Thing";
|
||||||
|
|
||||||
|
/// WebDAV 凭据的 key(**历史值,不可更改**)。
|
||||||
|
pub const KEY_WEBDAV: &str = "webdav-credentials";
|
||||||
|
/// QQ 音乐 Cookie 的 key(前端 `musicStore` 使用)。
|
||||||
|
pub const KEY_QQ_COOKIE: &str = "music-qq-cookie";
|
||||||
|
|
||||||
|
/// 允许**前端**通过 `music_secret_*` 命令读写的凭据键白名单。
|
||||||
|
///
|
||||||
|
/// 用白名单而不是前缀匹配:前端不该有能力枚举/试探凭据库,
|
||||||
|
/// `webdav-credentials` 与飞牛 token 因此都在前端的可达范围之外。
|
||||||
|
/// 新增键必须在此显式登记(改 Rust 代码),这是一道有意的闸门。
|
||||||
|
pub const FRONTEND_KEYS: [&str; 1] = [KEY_QQ_COOKIE];
|
||||||
|
|
||||||
|
/// 飞牛连接 token 的 key 前缀(**只由后端使用**,不暴露给前端)。
|
||||||
|
const TOKEN_KEY_PREFIX: &str = "music-feiniu-token-";
|
||||||
|
|
||||||
|
/// 某条飞牛连接的登录 token 的 key。
|
||||||
|
pub fn feiniu_token_key(connection_id: &str) -> String {
|
||||||
|
format!("{TOKEN_KEY_PREFIX}{connection_id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 前端是否允许访问该凭据键。
|
||||||
|
pub fn frontend_key_allowed(key: &str) -> bool {
|
||||||
|
FRONTEND_KEYS.contains(&key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取明文(未配置 → `Ok(None)`)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn secret_read(key: &str) -> Result<Option<String>, String> {
|
||||||
|
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?;
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(v) => Ok(Some(v)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(format!("读取凭据失败: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 写入明文(覆盖式)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn secret_write(key: &str, value: &str) -> Result<(), String> {
|
||||||
|
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?;
|
||||||
|
entry.set_password(value).map_err(|e| format!("保存凭据失败: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除凭据(不存在视为成功)。
|
||||||
|
#[cfg(windows)]
|
||||||
|
pub fn secret_delete(key: &str) -> Result<(), String> {
|
||||||
|
let entry = keyring::Entry::new(SERVICE, key).map_err(|e| format!("无法访问系统凭据管理器: {e}"))?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(format!("删除凭据失败: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn secret_read(_key: &str) -> Result<Option<String>, String> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn secret_write(_key: &str, _value: &str) -> Result<(), String> {
|
||||||
|
Err("当前平台不支持系统凭据管理器".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
pub fn secret_delete(_key: &str) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 把明文**尽力**迁入凭据库(失败只记日志,不抛错)。
|
||||||
|
///
|
||||||
|
/// 返回「明文是否可以安全清除」:只有写入成功才为 true。
|
||||||
|
/// 调用方用这个返回值决定要不要清空内存/配置里的明文。
|
||||||
|
pub fn try_store(key: &str, value: &str) -> bool {
|
||||||
|
if value.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match secret_write(key, value) {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(e) => {
|
||||||
|
logger::log_error(
|
||||||
|
"music",
|
||||||
|
&format!("凭据 {key} 写入系统凭据管理器失败(保留明文作为降级): {e}"),
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取飞牛连接 token;未配置或读取失败 → 空串(等价于未登录)。
|
||||||
|
pub fn read_feiniu_token(connection_id: &str) -> String {
|
||||||
|
if connection_id.is_empty() {
|
||||||
|
return String::new();
|
||||||
|
}
|
||||||
|
secret_read(&feiniu_token_key(connection_id))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 该连接是否已有可用 token(供前端展示「已登录」)。
|
||||||
|
pub fn has_feiniu_token(connection_id: &str) -> bool {
|
||||||
|
!read_feiniu_token(connection_id).is_empty()
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
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 { useFeiniuStore } from '@/stores/feiniuStore'
|
||||||
import ScrubBar from '@/components/common/ScrubBar.vue'
|
import ScrubBar from '@/components/common/ScrubBar.vue'
|
||||||
import SegmentedNav from '@/components/common/SegmentedNav.vue'
|
import SegmentedNav from '@/components/common/SegmentedNav.vue'
|
||||||
@@ -210,10 +210,11 @@ function toggleAt(index: number) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
:class="{ 'text-foreground': store.nowPlayingTab === 'queue' }"
|
:class="{ 'text-foreground': store.nowPlayingTab === 'queue' }"
|
||||||
aria-label="播放队列"
|
:aria-label="`播放队列(${store.queue.length})`"
|
||||||
|
:title="`播放队列(${store.queue.length})`"
|
||||||
@click="store.nowPlayingTab = 'queue'"
|
@click="store.nowPlayingTab = 'queue'"
|
||||||
>
|
>
|
||||||
<Plus class="size-4" />
|
<ListMusic class="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import {
|
import {
|
||||||
Disc3,
|
Disc3,
|
||||||
@@ -17,13 +17,116 @@ import {
|
|||||||
VolumeX
|
VolumeX
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useFeiniuStore } from '@/stores/feiniuStore'
|
import { useFeiniuStore } from '@/stores/feiniuStore'
|
||||||
|
import { createLogger } from '@/lib/logger'
|
||||||
import ScrubBar from '@/components/common/ScrubBar.vue'
|
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 store = useFeiniuStore()
|
||||||
|
const logger = createLogger('music-widget')
|
||||||
|
|
||||||
const open = ref(false)
|
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 coverUrl = computed(() => {
|
||||||
const t = store.nowPlaying
|
const t = store.nowPlaying
|
||||||
if (!t) return ''
|
if (!t) return ''
|
||||||
@@ -84,44 +187,81 @@ watch(
|
|||||||
store.clearPlayError()
|
store.clearPlayError()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 应用启动即初始化音乐 store(幂等):恢复播放列表/播放进度并预载上次在播的曲目。
|
||||||
|
// 这样标题栏控件不必等用户先进「音乐库」页,也能显示并续播上次的歌。
|
||||||
|
onMounted(() => {
|
||||||
|
store.init().catch((e) => logger.error(`音乐初始化失败: ${e}`))
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Popover v-model:open="open">
|
<Popover v-model:open="open">
|
||||||
<PopoverTrigger as-child>
|
<PopoverAnchor as-child>
|
||||||
<!-- 音乐栏:唱片图标 + 歌名(未播放过时只显示图标) -->
|
<!-- 音乐栏:图标 = 播放/暂停(悬停有遮罩),标题 = 打开控制窗;
|
||||||
<button
|
悬停 0.3s 也会打开控制窗。容器本身不响应点击,避免与图标语义冲突 -->
|
||||||
type="button"
|
<div
|
||||||
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"
|
class="mr-2 flex h-7 max-w-[210px] items-center gap-2 rounded-md px-1.5 transition-colors hover:bg-secondary/60"
|
||||||
:title="hasTrack ? `${title}${subtitle ? ' · ' + subtitle : ''}` : '未在播放'"
|
@mouseenter="onTriggerEnter"
|
||||||
:aria-label="hasTrack ? `音乐控制:${title}` : '音乐控制'"
|
@mouseleave="onTriggerLeave"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
>
|
>
|
||||||
<span
|
<!-- 唱片图标:点击开始/暂停 -->
|
||||||
class="disc flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full"
|
<button
|
||||||
:class="{ 'is-playing': store.playing }"
|
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-else-if="coverUrl && !coverFailed"
|
||||||
|
:src="coverUrl"
|
||||||
|
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="!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>
|
||||||
|
|
||||||
|
<!-- 标题:点击打开播放控制窗 -->
|
||||||
|
<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"
|
||||||
>
|
>
|
||||||
<img
|
|
||||||
v-if="coverUrl && !coverFailed"
|
|
||||||
:src="coverUrl"
|
|
||||||
class="size-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 }}
|
{{ title }}
|
||||||
</span>
|
</button>
|
||||||
</button>
|
</div>
|
||||||
</PopoverTrigger>
|
</PopoverAnchor>
|
||||||
|
|
||||||
<!-- 方形播放控制窗 -->
|
<!-- 方形播放控制窗 -->
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
align="end"
|
align="end"
|
||||||
:side-offset="8"
|
:side-offset="8"
|
||||||
class="w-[300px] p-3"
|
class="w-[300px] p-3"
|
||||||
|
@mouseenter="onContentEnter"
|
||||||
|
@mouseleave="onContentLeave"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center gap-3">
|
<div class="flex flex-col items-center gap-3">
|
||||||
|
|||||||
+30
-2
@@ -254,7 +254,18 @@ export const commands = {
|
|||||||
musicStopBridge: () => __TAURI_INVOKE<null>("music_stop_bridge"),
|
musicStopBridge: () => __TAURI_INVOKE<null>("music_stop_bridge"),
|
||||||
/** 读取音乐模块设置 */
|
/** 读取音乐模块设置 */
|
||||||
musicGetSettings: () => __TAURI_INVOKE<MusicSettings>("music_get_settings"),
|
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 }),
|
musicSaveSettings: (settings: MusicSettings) => __TAURI_INVOKE<null>("music_save_settings", { settings }),
|
||||||
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
|
/** 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 */
|
||||||
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
|
screenshotDisableTransitions: (label: string) => __TAURI_INVOKE<null>("screenshot_disable_transitions", { label }),
|
||||||
@@ -567,6 +578,11 @@ export type FeiniuConnection = {
|
|||||||
insecure: boolean,
|
insecure: boolean,
|
||||||
/** fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id) */
|
/** fnconnect 连接的 fnId(如 fnos.net/<id> 或裸 id) */
|
||||||
fnId?: string,
|
fnId?: string,
|
||||||
|
/**
|
||||||
|
* 是否经由 FnConnect 中继链路(`<fnId>.fnos.net`)。
|
||||||
|
* 中继要求所有请求携带 `Cookie: mode=relay`,否则网关 302 回登录页。
|
||||||
|
*/
|
||||||
|
relay?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FileEntry = {
|
export type FileEntry = {
|
||||||
@@ -623,6 +639,13 @@ export type MusicEnvStatus = {
|
|||||||
musicdlInstalled: boolean,
|
musicdlInstalled: boolean,
|
||||||
/** musicdl 版本 */
|
/** musicdl 版本 */
|
||||||
musicdlVersion: string | null,
|
musicdlVersion: string | null,
|
||||||
|
/** 本应用锁定的 musicdl 版本(`MUSICDL_VERSION`):是否过期、更新到哪个版本都以它为准 */
|
||||||
|
musicdlExpected: string,
|
||||||
|
/**
|
||||||
|
* 已装 musicdl 是否与锁定版本不一致。
|
||||||
|
* 未安装时恒为 false(那是「安装」引导的事,不是「更新」)。
|
||||||
|
*/
|
||||||
|
musicdlOutdated: boolean,
|
||||||
/** FFmpeg 是否可用(部分音源需要,非必需) */
|
/** FFmpeg 是否可用(部分音源需要,非必需) */
|
||||||
ffmpeg: string | null,
|
ffmpeg: string | null,
|
||||||
/** 桥接进程是否在运行 */
|
/** 桥接进程是否在运行 */
|
||||||
@@ -651,6 +674,11 @@ export type MusicSettings = {
|
|||||||
selectQualityOnDownload: boolean,
|
selectQualityOnDownload: boolean,
|
||||||
/** 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K") */
|
/** 下载时默认音质:"" 表示最高;否则为搜索音质档位 label(如 "无损"、"320K") */
|
||||||
defaultDownloadQuality: string,
|
defaultDownloadQuality: string,
|
||||||
|
/**
|
||||||
|
* QQ 音乐 Cookie(可选):用于解析需要登录的歌单(含自己的隐私歌单)与 VIP 音质。
|
||||||
|
* 传给 musicdl 的 default_search/parse/download_cookies;空=游客身份。
|
||||||
|
*/
|
||||||
|
qqCookie?: string,
|
||||||
/** 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置) */
|
/** 飞牛音乐(NAS)连接:服务器地址(如 http://192.168.1.10:5666,空=未配置) */
|
||||||
feiniuBaseUrl?: string,
|
feiniuBaseUrl?: string,
|
||||||
/** 飞牛音乐登录 token(登录成功后保存) */
|
/** 飞牛音乐登录 token(登录成功后保存) */
|
||||||
@@ -661,7 +689,7 @@ export type MusicSettings = {
|
|||||||
feiniuDeviceId?: string,
|
feiniuDeviceId?: string,
|
||||||
/** 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空) */
|
/** 飞牛音乐访问安全码(可选;仅需访问码的库才填,LAN 通常为空) */
|
||||||
feiniuAccessCode?: string,
|
feiniuAccessCode?: string,
|
||||||
/** 飞牛音乐连接列表(多连接:本地 / frp / 预留 fnconnect) */
|
/** 飞牛音乐连接列表(多连接:局域网 / FnConnect) */
|
||||||
feiniuConnections?: FeiniuConnection[],
|
feiniuConnections?: FeiniuConnection[],
|
||||||
/** 当前激活连接的 id */
|
/** 当前激活连接的 id */
|
||||||
feiniuActiveId?: string,
|
feiniuActiveId?: string,
|
||||||
|
|||||||
@@ -29,16 +29,18 @@ import { useModuleTabsStore, type ModuleTab } from '@/stores/moduleTabsStore'
|
|||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* ## 工作原理
|
* ## 工作原理
|
||||||
* 1. onMounted 时注册标签到 moduleTabsStore,TitleBar 据此渲染浮动切换器
|
* 1. onMounted 时注册标签到 moduleTabsStore(**并复用该模块上次停留的 tab**),
|
||||||
|
* TitleBar 据此渲染浮动切换器
|
||||||
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
|
* 2. 用 IntersectionObserver 监听 TabsList 可见性(rootMargin 裁剪 TitleBar 高度)
|
||||||
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
|
* 3. 双向 watch 同步本地 activeTab 与 store.activeTab
|
||||||
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
|
* 4. 消费搜索导航的待跳转 tab(模块尚未挂载的场景)
|
||||||
* 5. onUnmounted 时清理 observer 并注销标签
|
* 5. onUnmounted 时清理 observer 并把当前 tab 记到 store(按模块)后注销标签
|
||||||
*
|
*
|
||||||
* ## 约束
|
* ## 约束
|
||||||
* - TitleBar 高度固定为 40px (h-10),composable 内部已用 44px 裁剪(含缓冲)
|
* - TitleBar 高度固定为 40px (h-10),composable 内部已用 44px 裁剪(含缓冲)
|
||||||
* - 一个模块同一时间只能注册一组标签(store 是单例)
|
* - 一个模块同一时间只能注册一组标签(store 是单例)
|
||||||
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
|
* - 模块卸载时务必让 composable 的 onUnmounted 执行(已自动处理,无需手动调用)
|
||||||
|
* - tab 记忆只保内存、不落盘:tab 集合可能随版本变化,跨重启恢复旧值风险更大
|
||||||
*/
|
*/
|
||||||
export function useModuleTabs(
|
export function useModuleTabs(
|
||||||
moduleId: string,
|
moduleId: string,
|
||||||
@@ -97,10 +99,12 @@ export function useModuleTabs(
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
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()
|
await nextTick()
|
||||||
setupObserver()
|
setupObserver()
|
||||||
// 搜索导航跳转:模块刚挂载,消费待跳转 tab
|
// 搜索导航跳转:模块刚挂载,消费待跳转 tab(优先级高于上次停留)
|
||||||
applyPendingTab()
|
applyPendingTab()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,7 +113,7 @@ export function useModuleTabs(
|
|||||||
observer.disconnect()
|
observer.disconnect()
|
||||||
observer = null
|
observer = null
|
||||||
}
|
}
|
||||||
tabsStore.unregisterTabs()
|
tabsStore.unregisterTabs(moduleId)
|
||||||
})
|
})
|
||||||
|
|
||||||
return tabsListRef
|
return tabsListRef
|
||||||
|
|||||||
+466
-363
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 { toast } from 'vue-sonner'
|
||||||
import {
|
import {
|
||||||
Cloud,
|
Cloud,
|
||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Trash2
|
Trash2
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useFeiniuStore } from '@/stores/feiniuStore'
|
import { useFeiniuStore, type PlayableItem } from '@/stores/feiniuStore'
|
||||||
import TrackItem from './TrackItem.vue'
|
import TrackItem from './TrackItem.vue'
|
||||||
import PlaylistEditorDialog from './PlaylistEditorDialog.vue'
|
import PlaylistEditorDialog from './PlaylistEditorDialog.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -46,21 +46,96 @@ const sourceNav = computed(() => [
|
|||||||
{ value: 'playlists' as const, label: '我的歌单', icon: ListMusic, count: store.playlists.length }
|
{ value: 'playlists' as const, label: '我的歌单', icon: ListMusic, count: store.playlists.length }
|
||||||
])
|
])
|
||||||
|
|
||||||
// ===== 飞牛曲库:搜索(直接绑定 store.searchKeyword,此前绑定的是孤立局部变量,导致搜索完全无效)=====
|
// ===== 曲库搜索:飞牛 track/list 接口**不支持**关键字过滤(实测忽略该参数),
|
||||||
|
// 因此搜索框只做**本地筛选**(歌名/歌手/专辑);输入时自动补齐剩余分页,
|
||||||
|
// 保证筛选覆盖全库而不是已加载的前几页。两个视图(飞牛/本地)共用同一个关键字。
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let searchSeq = 0
|
let searchSeq = 0
|
||||||
|
/** 全量补齐进行中:输入防抖与视图切换可能同时触发,用标志位避免并发重复翻页 */
|
||||||
|
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.loadRemainingTracks()
|
||||||
|
} catch (e) {
|
||||||
|
if (seq === searchSeq) toast.error(String(e))
|
||||||
|
} finally {
|
||||||
|
fillingAll = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索框输入:仅飞牛曲库需要补齐分页(本地曲库是已全量在内存的数组,输入即筛)。
|
||||||
|
* 两个视图共用同一个关键字,但按各自的数据来源决定是否发请求。
|
||||||
|
*/
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
clearTimeout(searchTimer)
|
clearTimeout(searchTimer)
|
||||||
searchTimer = setTimeout(async () => {
|
searchTimer = setTimeout(() => {
|
||||||
const seq = ++searchSeq
|
if (view.value !== 'feiniu') return
|
||||||
try {
|
void fillRemainingFeiniu()
|
||||||
await store.loadTracks(1)
|
}, 300)
|
||||||
} catch (e) {
|
|
||||||
if (seq === searchSeq) toast.error(String(e))
|
|
||||||
}
|
|
||||||
}, 400)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function refreshFeiniu() {
|
||||||
store.loadTracks(1).catch((e) => toast.error(String(e)))
|
store.loadTracks(1).catch((e) => toast.error(String(e)))
|
||||||
}
|
}
|
||||||
@@ -69,23 +144,66 @@ function refreshLocal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function playFeiniu() {
|
function playFeiniu() {
|
||||||
if (store.tracks.length) store.playQueue(store.tracks, 0)
|
// 有筛选时只播放筛选结果
|
||||||
|
if (filteredTracks.value.length) store.playQueue(filteredTracks.value, 0)
|
||||||
}
|
}
|
||||||
function playLocal() {
|
function playLocal() {
|
||||||
if (store.localTracks.length) store.playQueue(store.localTracks, 0)
|
if (filteredLocalTracks.value.length) store.playQueue(filteredLocalTracks.value, 0)
|
||||||
}
|
}
|
||||||
function playActivePlaylist() {
|
function playActivePlaylist() {
|
||||||
if (activePlaylist.value?.items.length) store.playQueue(activePlaylist.value.items, 0)
|
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)
|
const loadMoreRef = ref<HTMLElement | null>(null)
|
||||||
let loadMoreObserver: IntersectionObserver | null = null
|
let loadMoreObserver: IntersectionObserver | null = null
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMoreObserver = new IntersectionObserver(
|
loadMoreObserver = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
if (!entries.some((e) => e.isIntersecting)) return
|
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' }
|
{ rootMargin: '400px' }
|
||||||
)
|
)
|
||||||
@@ -118,6 +236,11 @@ const editorOpen = ref(false)
|
|||||||
// 切换歌单时关闭编辑器,避免对着已切换的目标继续编辑
|
// 切换歌单时关闭编辑器,避免对着已切换的目标继续编辑
|
||||||
watch(activePlaylistId, () => (editorOpen.value = false))
|
watch(activePlaylistId, () => (editorOpen.value = false))
|
||||||
|
|
||||||
|
// 换视图 / 换关键字 / 换歌单时把增量渲染窗口收回一屏,避免「切回来看到一屏旧内容」
|
||||||
|
watch([view, filterKeyword, activePlaylistId], () => {
|
||||||
|
renderLimit.value = LIST_STEP
|
||||||
|
})
|
||||||
|
|
||||||
// 切到歌单视图时自动选中第一个
|
// 切到歌单视图时自动选中第一个
|
||||||
watch(
|
watch(
|
||||||
[view, () => store.playlists.length],
|
[view, () => store.playlists.length],
|
||||||
@@ -159,9 +282,44 @@ function removePlaylist() {
|
|||||||
activePlaylistId.value = ''
|
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 () => {
|
onMounted(async () => {
|
||||||
await store.init()
|
try {
|
||||||
if (store.config.loggedIn) store.loadTracks(1).catch(() => {})
|
// 恢复上次的视图与筛选关键字
|
||||||
|
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) await store.ensureTracksFresh().catch(() => {})
|
||||||
|
// 恢复的是本地视图时自动扫描(标签有缓存,通常很快),否则恢复的筛选无内容可筛
|
||||||
|
if (view.value === 'local') void store.scanLocal().catch(() => {})
|
||||||
|
} finally {
|
||||||
|
// 挂载初始化结束才让「视图 / 关键字变化 → 补齐分页」的监听生效,
|
||||||
|
// 避免它与上面的首页请求并发(并发会因列表整体替换而丢页);
|
||||||
|
// 随后按同一串行顺序补一次,覆盖「启动时已带关键字」的情况
|
||||||
|
mountedReady = true
|
||||||
|
ensureFeiniuFilterCoverage()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
/** 表格表头与 TrackItem 行保持同一条网格 */
|
/** 表格表头与 TrackItem 行保持同一条网格 */
|
||||||
@@ -169,10 +327,9 @@ const GRID = { gridTemplateColumns: '28px 36px minmax(0,1fr) 74px 28px' }
|
|||||||
|
|
||||||
const listEmptyHint = computed(() => {
|
const listEmptyHint = computed(() => {
|
||||||
if (view.value === 'feiniu') {
|
if (view.value === 'feiniu') {
|
||||||
if (store.searchKeyword.trim()) return '没有匹配「' + store.searchKeyword.trim() + '」的曲目'
|
|
||||||
return store.config.loggedIn ? '曲库为空,试试刷新或检查 NAS 曲库目录' : '请先在「设置 → 飞牛音乐连接」登录'
|
return store.config.loggedIn ? '曲库为空,试试刷新或检查 NAS 曲库目录' : '请先在「设置 → 飞牛音乐连接」登录'
|
||||||
}
|
}
|
||||||
return '暂无本地音乐,点击「扫描本地曲库」或先到「发现音乐」下载'
|
return '暂无本地音乐,点击「扫描本地曲库」,或到「设置 → 存储与上传」添加本地曲库目录'
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -249,15 +406,19 @@ const listEmptyHint = computed(() => {
|
|||||||
|
|
||||||
<!-- ===== 右:内容(key 随来源变化,复用全局 tab-animate 切换动画) ===== -->
|
<!-- ===== 右:内容(key 随来源变化,复用全局 tab-animate 切换动画) ===== -->
|
||||||
<section :key="view" class="tab-animate flex min-w-0 flex-1 flex-col">
|
<section :key="view" class="tab-animate flex min-w-0 flex-1 flex-col">
|
||||||
<!-- 未登录态 -->
|
<!-- 未登录态:**只挡飞牛曲库**。本地曲库与「我的歌单」完全不依赖 NAS 登录,
|
||||||
<div v-if="needsLogin" class="flex min-h-0 flex-1 items-center justify-center px-6">
|
此前把它们一起挡掉,等于让「只用发现音乐 + 本地曲库」的用户点进来只看得到登录提示 -->
|
||||||
|
<div
|
||||||
|
v-if="view === 'feiniu' && needsLogin"
|
||||||
|
class="flex min-h-0 flex-1 items-center justify-center px-6"
|
||||||
|
>
|
||||||
<Empty>
|
<Empty>
|
||||||
<EmptyMedia>
|
<EmptyMedia>
|
||||||
<Music2 class="size-8 text-muted-foreground" />
|
<Music2 class="size-8 text-muted-foreground" />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>还没有可用的飞牛音乐连接</EmptyTitle>
|
<EmptyTitle>飞牛曲库需要一个可用的音乐连接</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
到「设置 → 飞牛音乐连接」添加并登录,或先到「发现音乐」下载歌曲到本地曲库。
|
到「设置 → 飞牛音乐连接」添加并登录。本地曲库与「我的歌单」不依赖 NAS,可直接在左侧切换查看。
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</Empty>
|
</Empty>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,36 +427,65 @@ const listEmptyHint = computed(() => {
|
|||||||
<!-- 工具栏 -->
|
<!-- 工具栏 -->
|
||||||
<header class="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
<header class="flex shrink-0 items-center gap-2 border-b px-4 py-2.5">
|
||||||
<template v-if="view === 'feiniu'">
|
<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" />
|
<Search class="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
v-model="store.searchKeyword"
|
v-model="store.searchKeyword"
|
||||||
class="h-8 pl-8"
|
class="h-8 pl-8"
|
||||||
placeholder="搜索歌名 / 歌手 / 专辑"
|
placeholder="搜索歌名 / 歌手 / 专辑(本地筛选)"
|
||||||
@input="onSearchInput"
|
@input="onSearchInput"
|
||||||
@keydown.enter="onSearchInput"
|
@keydown.enter="onSearchInput"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" class="h-8" :disabled="store.loading" @click="refreshFeiniu">
|
<Button
|
||||||
<RefreshCw :class="store.loading ? 'size-3.5 animate-spin' : 'size-3.5'" />
|
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>
|
</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">
|
||||||
{{ store.tracks.length }}<template v-if="store.total > store.tracks.length"> / {{ store.total }}</template> 首
|
<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>
|
</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" /> 播放全部
|
<Play class="size-3.5" /> 播放全部
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="view === 'local'">
|
<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">
|
<Button variant="outline" size="sm" class="h-8" :disabled="store.localScanBusy" @click="refreshLocal">
|
||||||
<Loader2 v-if="store.localScanBusy" class="size-3.5 animate-spin" />
|
<Loader2 v-if="store.localScanBusy" class="size-3.5 animate-spin" />
|
||||||
<FolderOpen v-else class="size-3.5" />
|
<FolderOpen v-else class="size-3.5" />
|
||||||
扫描本地曲库
|
扫描本地曲库
|
||||||
</Button>
|
</Button>
|
||||||
<span class="text-[11.5px] text-muted-foreground">{{ store.localTracks.length }} 首</span>
|
<span class="ml-1 shrink-0 whitespace-nowrap text-[11.5px] text-muted-foreground">
|
||||||
<Button size="sm" class="ml-auto h-8" :disabled="!store.localTracks.length" @click="playLocal">
|
<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" /> 播放全部
|
<Play class="size-3.5" /> 播放全部
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
@@ -344,8 +534,8 @@ const listEmptyHint = computed(() => {
|
|||||||
<!-- 表头 -->
|
<!-- 表头 -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
(view === 'feiniu' && store.tracks.length) ||
|
(view === 'feiniu' && filteredTracks.length) ||
|
||||||
(view === 'local' && store.localTracks.length) ||
|
(view === 'local' && filteredLocalTracks.length) ||
|
||||||
(view === 'playlists' && activePlaylist?.items.length)
|
(view === 'playlists' && activePlaylist?.items.length)
|
||||||
"
|
"
|
||||||
class="grid h-8 items-center gap-3 px-2 text-[11px] text-muted-foreground"
|
class="grid h-8 items-center gap-3 px-2 text-[11px] text-muted-foreground"
|
||||||
@@ -365,26 +555,25 @@ const listEmptyHint = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
<Empty v-else-if="!store.tracks.length">
|
<Empty v-else-if="!store.tracks.length">
|
||||||
<EmptyMedia><Music2 class="size-8 text-muted-foreground" /></EmptyMedia>
|
<EmptyMedia><Music2 class="size-8 text-muted-foreground" /></EmptyMedia>
|
||||||
<EmptyTitle>{{ store.searchKeyword.trim() ? '没有匹配的曲目' : '曲库为空' }}</EmptyTitle>
|
<EmptyTitle>曲库为空</EmptyTitle>
|
||||||
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
|
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
|
||||||
</Empty>
|
</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>
|
<template v-else>
|
||||||
<TrackItem
|
<TrackItem
|
||||||
v-for="(t, i) in store.tracks"
|
v-for="(t, i) in visibleFeiniu"
|
||||||
:key="t.guid || i"
|
:key="t.guid || i"
|
||||||
:item="t"
|
:item="t"
|
||||||
:index="i"
|
:index="i"
|
||||||
:context="store.tracks"
|
:context="filteredTracks"
|
||||||
:active="store.current?.guid === t.guid"
|
: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>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -395,13 +584,18 @@ const listEmptyHint = computed(() => {
|
|||||||
<EmptyTitle>暂无本地音乐</EmptyTitle>
|
<EmptyTitle>暂无本地音乐</EmptyTitle>
|
||||||
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
|
<EmptyDescription>{{ listEmptyHint }}</EmptyDescription>
|
||||||
</Empty>
|
</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
|
<TrackItem
|
||||||
v-for="(t, i) in store.localTracks"
|
v-for="(t, i) in visibleLocal"
|
||||||
:key="t.guid || i"
|
:key="t.guid || i"
|
||||||
:item="t"
|
:item="t"
|
||||||
:index="i"
|
:index="i"
|
||||||
:context="store.localTracks"
|
:context="filteredLocalTracks"
|
||||||
:active="store.current?.guid === t.guid"
|
:active="store.current?.guid === t.guid"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -422,7 +616,7 @@ const listEmptyHint = computed(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Empty>
|
</Empty>
|
||||||
<TrackItem
|
<TrackItem
|
||||||
v-for="(t, i) in activePlaylist.items"
|
v-for="(t, i) in visiblePlaylistItems"
|
||||||
:key="`${t.source}:${t.guid}:${i}`"
|
:key="`${t.source}:${t.guid}:${i}`"
|
||||||
:item="t"
|
:item="t"
|
||||||
:index="i"
|
:index="i"
|
||||||
@@ -439,6 +633,17 @@ const listEmptyHint = computed(() => {
|
|||||||
<EmptyDescription>点击左侧「新建歌单」开始整理你的收藏。</EmptyDescription>
|
<EmptyDescription>点击左侧「新建歌单」开始整理你的收藏。</EmptyDescription>
|
||||||
</Empty>
|
</Empty>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -106,26 +106,26 @@ async function uploadToFeiniu() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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="[
|
:class="[
|
||||||
showAlbum
|
showAlbum
|
||||||
? 'grid-cols-[28px_36px_minmax(0,1fr)_minmax(0,0.75fr)_74px_28px]'
|
? 'grid-cols-[28px_36px_minmax(0,1fr)_minmax(0,0.75fr)_74px_28px]'
|
||||||
: 'grid-cols-[28px_36px_minmax(0,1fr)_74px_28px]',
|
: 'grid-cols-[28px_36px_minmax(0,1fr)_74px_28px]',
|
||||||
isCurrent ? 'bg-accent/60' : 'hover:bg-accent/40'
|
isCurrent ? 'bg-accent/60' : 'hover:bg-accent/40'
|
||||||
]"
|
]"
|
||||||
role="button"
|
|
||||||
tabindex="0"
|
|
||||||
@dblclick="play"
|
@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:none→block,
|
||||||
|
而 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>
|
<span v-if="isCurrent" class="music-eq" :class="{ 'is-paused': !store.playing }"><i /><i /><i /></span>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="group-hover:hidden">{{ index + 1 }}</span>
|
<span class="transition-opacity group-hover:opacity-0">{{ index + 1 }}</span>
|
||||||
<button
|
<button
|
||||||
type="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"
|
:aria-label="'播放 ' + item.title"
|
||||||
@click.stop="play"
|
@click.stop="play"
|
||||||
>
|
>
|
||||||
@@ -223,9 +223,11 @@ async function uploadToFeiniu() {
|
|||||||
>
|
>
|
||||||
<Cloud class="size-4" /> 上传到飞牛曲库
|
<Cloud class="size-4" /> 上传到飞牛曲库
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem disabled>
|
<!-- 所在目录只是信息,不是动作:用 disabled 的菜单项展示会让人以为点了没反应 -->
|
||||||
<FolderOpen class="size-4" /> {{ item.dir || '未知目录' }}
|
<div class="flex items-center gap-2 px-2 py-1.5 text-[11.5px] text-muted-foreground">
|
||||||
</DropdownMenuItem>
|
<FolderOpen class="size-3.5 shrink-0" />
|
||||||
|
<span class="truncate" :title="item.dir || ''">{{ item.dir || '未知目录' }}</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-if="playlistId">
|
<template v-if="playlistId">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const editing = ref<Partial<FeiniuConnection> | null>(null)
|
|||||||
const editOpen = ref(false)
|
const editOpen = ref(false)
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const testing = ref(false)
|
const testing = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
function newForm() {
|
function newForm() {
|
||||||
editing.value = { name: '', kind: 'lan', baseUrl: '', username: '', accessCode: '', insecure: false, fnId: '' }
|
editing.value = { name: '', kind: 'lan', baseUrl: '', username: '', accessCode: '', insecure: false, fnId: '' }
|
||||||
@@ -30,46 +31,94 @@ function newForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editForm(c: FeiniuConnection) {
|
function editForm(c: FeiniuConnection) {
|
||||||
editing.value = { ...c }
|
// 历史数据里的 frp 等内网转发方式已并入「局域网」
|
||||||
|
editing.value = { ...c, kind: c.kind === 'fnconnect' ? 'fnconnect' : 'lan' }
|
||||||
password.value = ''
|
password.value = ''
|
||||||
editOpen.value = true
|
editOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!editing.value) return
|
if (!editing.value || saving.value) return
|
||||||
if (!editing.value.name?.trim() || !editing.value.baseUrl?.trim()) {
|
const draft = { ...editing.value }
|
||||||
toast.error('请填写名称与服务器地址')
|
if (!draft.name?.trim()) {
|
||||||
|
toast.error('请填写名称')
|
||||||
return
|
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 {
|
try {
|
||||||
const id = await store.saveConnection({
|
const id = await store.saveConnection({
|
||||||
id: editing.value.id || '',
|
id: draft.id || '',
|
||||||
name: editing.value.name.trim(),
|
name: draft.name.trim(),
|
||||||
kind: editing.value.kind || 'lan',
|
kind: draft.kind || 'lan',
|
||||||
baseUrl: editing.value.baseUrl.trim(),
|
baseUrl: draft.baseUrl?.trim() || '',
|
||||||
username: editing.value.username || '',
|
username: draft.username || '',
|
||||||
accessCode: editing.value.accessCode || '',
|
accessCode: draft.accessCode || '',
|
||||||
insecure: !!editing.value.insecure,
|
insecure: !!draft.insecure,
|
||||||
fnId: editing.value.fnId || ''
|
fnId: draft.fnId?.trim() || '',
|
||||||
|
relay: !!draft.relay
|
||||||
})
|
})
|
||||||
if (password.value) {
|
// 先关闭对话框再登录:FnConnect 登录要先探测可达地址(可能数秒),
|
||||||
await store.login(id, editing.value.username || '', password.value)
|
// 若等待其完成才关窗,观感就是「已保存但对话框卡住」。
|
||||||
}
|
|
||||||
editOpen.value = false
|
editOpen.value = false
|
||||||
toast.success('已保存')
|
toast.success('已保存')
|
||||||
|
if (pw && id) {
|
||||||
|
void store
|
||||||
|
.login(id, draft.username || '', pw)
|
||||||
|
.then(() => toast.success('登录成功'))
|
||||||
|
.catch((e) => toast.error(`登录失败:${e}`))
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(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) {
|
if (!password.value) {
|
||||||
toast.error('请输入密码再测试')
|
toast.error('请输入密码再测试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (d.kind === 'fnconnect') {
|
||||||
|
if (!d.fnId?.trim()) {
|
||||||
|
toast.error('请填写飞牛 ID')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (!d.baseUrl?.trim()) {
|
||||||
|
toast.error('请填写服务器地址')
|
||||||
|
return
|
||||||
|
}
|
||||||
testing.value = true
|
testing.value = true
|
||||||
try {
|
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('连接成功')
|
toast.success('连接成功')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(String(e))
|
toast.error(String(e))
|
||||||
@@ -93,8 +142,9 @@ async function remove(c: FeiniuConnection) {
|
|||||||
toast.success('已删除')
|
toast.success('已删除')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** kind → 展示名:局域网涵盖 frp / 内网转发等直连方式,其余为 FnConnect */
|
||||||
function kindLabel(k: string) {
|
function kindLabel(k: string) {
|
||||||
return k === 'lan' ? '局域网' : k === 'frp' ? 'frp 域名' : 'FnConnect'
|
return k === 'fnconnect' ? 'FnConnect' : '局域网'
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => store.refreshConnections())
|
onMounted(() => store.refreshConnections())
|
||||||
@@ -122,7 +172,8 @@ onMounted(() => store.refreshConnections())
|
|||||||
<Badge v-if="store.activeId === c.id" variant="default" class="text-[10px]">激活</Badge>
|
<Badge v-if="store.activeId === c.id" variant="default" class="text-[10px]">激活</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate text-xs text-muted-foreground">
|
<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>
|
</div>
|
||||||
<div class="flex shrink-0 items-center gap-1">
|
<div class="flex shrink-0 items-center gap-1">
|
||||||
@@ -167,7 +218,7 @@ onMounted(() => store.refreshConnections())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!store.connections.length" class="text-xs text-muted-foreground">
|
<p v-if="!store.connections.length" class="text-xs text-muted-foreground">
|
||||||
还没有连接。新建一个并填写 NAS 地址(局域网 http://192.168.x.x:5666、frp 域名或 FnConnect fnId)。
|
还没有连接。新建一个并填写 NAS 地址(局域网 http://192.168.x.x:5666,frp 等内网转发同样填最终访问地址)或 FnConnect fnId。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,17 +230,17 @@ onMounted(() => store.refreshConnections())
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>名称</Label>
|
<Label>名称</Label>
|
||||||
<Input v-model="editing!.name" placeholder="如:家里 NAS / frp 远程 / FnConnect" />
|
<Input v-model="editing!.name" placeholder="如:家里 NAS / FnConnect 远程" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>类型</Label>
|
<Label>类型</Label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
v-for="k in (['lan', 'frp', 'fnconnect'] as const)"
|
v-for="k in (['lan', 'fnconnect'] as const)"
|
||||||
:key="k"
|
:key="k"
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
:variant="editing!.kind === k ? 'default' : 'outline'"
|
:variant="(editing!.kind ?? 'lan') === k ? 'default' : 'outline'"
|
||||||
@click="editing!.kind = k"
|
@click="editing!.kind = k"
|
||||||
>
|
>
|
||||||
{{ kindLabel(k) }}
|
{{ kindLabel(k) }}
|
||||||
@@ -197,15 +248,19 @@ onMounted(() => store.refreshConnections())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="editing!.kind === 'fnconnect'" class="space-y-1.5">
|
<div v-if="editing!.kind === 'fnconnect'" class="space-y-1.5">
|
||||||
<Label>FnConnect fnId(fnos.net/xxx 或裸 id)</Label>
|
<Label>飞牛 ID</Label>
|
||||||
<Input v-model="editing!.fnId" placeholder="fnos.net/zy2060537" />
|
<Input v-model="editing!.fnId" placeholder="请输入飞牛 ID,如 abc123" />
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
保存后点登录会自动解析到可达地址;服务器地址会回填。
|
即 fnos.net/ 后面的那段(也可直接粘贴 fnos.net/abc123)。
|
||||||
|
保存后点登录会自动解析到可达地址(内网 / 公网 / 中继),服务器地址会回填。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-1.5">
|
<div v-else class="space-y-1.5">
|
||||||
<Label>服务器地址</Label>
|
<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.com(frp 填转发后的地址)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>账号</Label>
|
<Label>账号</Label>
|
||||||
@@ -221,17 +276,14 @@ onMounted(() => store.refreshConnections())
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter class="gap-2">
|
<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" /> 测试
|
<Loader2 v-if="testing" class="size-4 animate-spin" /> 测试
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="save">保存</Button>
|
<Button :disabled="saving" @click="save">
|
||||||
|
<Loader2 v-if="saving" class="size-4 animate-spin" /> 保存
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
提示:上传音乐到 NAS(「下载到飞牛」/ 上传 / 删除)已改用 <strong class="font-medium">WebDAV</strong>,
|
|
||||||
在「存储与上传」分组里配置,无需在此登录文件服务。
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
+736
-82
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,17 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
|
|||||||
/** 待跳转 tab(搜索导航设置):{ moduleId, tab },模块挂载/已挂载时消费 */
|
/** 待跳转 tab(搜索导航设置):{ moduleId, tab },模块挂载/已挂载时消费 */
|
||||||
const pendingTab = ref<{ moduleId: string; tab: string } | null>(null)
|
const pendingTab = ref<{ moduleId: string; tab: string } | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 各模块上次停留的 tab(内存记忆)。
|
||||||
|
*
|
||||||
|
* 模块被切走时组件卸载、`activeTab` 随之丢失,再切回来总是回到第一个 tab——
|
||||||
|
* 对「曲库 / 发现音乐 / 设置」这种三级结构的模块尤其别扭。
|
||||||
|
* 只做内存记忆(不落盘):tab 集合可能随版本变化,跨版本恢复旧值反而容易出错。
|
||||||
|
*/
|
||||||
|
const lastTabByModule = ref<Record<string, string>>({})
|
||||||
|
/** 当前已注册的模块 id(unregister 时据此保存) */
|
||||||
|
let currentModuleId = ''
|
||||||
|
|
||||||
/** 设置待跳转 tab(搜索结果点击时调用) */
|
/** 设置待跳转 tab(搜索结果点击时调用) */
|
||||||
const setPendingTab = (moduleId: string, tab: string) => {
|
const setPendingTab = (moduleId: string, tab: string) => {
|
||||||
pendingTab.value = { moduleId, tab }
|
pendingTab.value = { moduleId, tab }
|
||||||
@@ -56,15 +67,30 @@ export const useModuleTabsStore = defineStore('moduleTabs', () => {
|
|||||||
/** 是否显示保存按钮(当前在设置 tab 且注册了保存处理函数) */
|
/** 是否显示保存按钮(当前在设置 tab 且注册了保存处理函数) */
|
||||||
const saveVisible = computed(() => saveHandler.value !== null && activeTab.value === 'settings')
|
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
|
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
|
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 = []
|
tabs.value = []
|
||||||
activeTab.value = ''
|
activeTab.value = ''
|
||||||
floatingVisible.value = false
|
floatingVisible.value = false
|
||||||
|
|||||||
+202
-31
@@ -119,6 +119,8 @@ const logger = createLogger('music')
|
|||||||
export const useMusicStore = defineStore('music', () => {
|
export const useMusicStore = defineStore('music', () => {
|
||||||
const settings = ref<MusicSettings | null>(null)
|
const settings = ref<MusicSettings | null>(null)
|
||||||
const env = ref<MusicEnvStatus | null>(null)
|
const env = ref<MusicEnvStatus | null>(null)
|
||||||
|
/** 环境探测进行中(探测要串行 spawn 4 个子进程,便携 Python 冷启动可达数秒) */
|
||||||
|
const envLoading = ref(false)
|
||||||
/** musicdl 已注册的全部搜索源(客户端名) */
|
/** musicdl 已注册的全部搜索源(客户端名) */
|
||||||
const availableSources = ref<string[]>([])
|
const availableSources = ref<string[]>([])
|
||||||
|
|
||||||
@@ -136,23 +138,118 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
const installError = ref('')
|
const installError = ref('')
|
||||||
let progressUnlisten: UnlistenFn | null = null
|
let progressUnlisten: UnlistenFn | null = null
|
||||||
|
|
||||||
async function init() {
|
/**
|
||||||
restoreTasks()
|
* 幂等初始化。
|
||||||
await Promise.all([loadSettings(), refreshEnv()])
|
*
|
||||||
|
* 标题栏音乐控件与音乐模块各会调用一次:不做守卫就会**重复**执行
|
||||||
|
* `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() {
|
async function loadSettings() {
|
||||||
settings.value = await commands.musicGetSettings()
|
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) */
|
/** 保存设置(调用方先修改 settings 再调用;前端用 400ms debounce) */
|
||||||
async function saveSettings() {
|
async function saveSettings() {
|
||||||
if (!settings.value) return
|
if (!settings.value) return
|
||||||
await commands.musicSaveSettings(settings.value)
|
await commands.musicSaveSettings(settings.value)
|
||||||
|
syncedSettingsJson = JSON.stringify(settings.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshEnv() {
|
async function refreshEnv() {
|
||||||
env.value = await commands.musicEnvStatus()
|
// 探测期间 `env` 可能是旧的/为 null,UI 必须靠这个标志区分
|
||||||
|
// 「正在检测」与「确实未安装」,否则冷启动会短暂误报「未安装」并诱导用户重装
|
||||||
|
envLoading.value = true
|
||||||
|
try {
|
||||||
|
env.value = await commands.musicEnvStatus()
|
||||||
|
} finally {
|
||||||
|
envLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSources() {
|
async function loadSources() {
|
||||||
@@ -169,11 +266,41 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
return (await invoke('music_ping')) as MusicPingPayload
|
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[]) {
|
async function search(keyword: string, sources: string[]) {
|
||||||
searching.value = true
|
searching.value = true
|
||||||
searchError.value = ''
|
searchError.value = ''
|
||||||
try {
|
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 ?? {}
|
results.value = v.results ?? {}
|
||||||
resultTotal.value = v.total ?? 0
|
resultTotal.value = v.total ?? 0
|
||||||
searched.value = true
|
searched.value = true
|
||||||
@@ -194,7 +321,12 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
parsing.value = true
|
parsing.value = true
|
||||||
parseError.value = ''
|
parseError.value = ''
|
||||||
try {
|
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[]
|
songs: MusicSong[]
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
@@ -376,7 +508,12 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
if (!song) return null
|
if (!song) return null
|
||||||
if (song.downloadUrl) return song
|
if (song.downloadUrl) return song
|
||||||
if (!song.rawSearch) return null
|
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)[]
|
songs: (MusicSong | null)[]
|
||||||
}
|
}
|
||||||
const resolved = v.songs?.[0]
|
const resolved = v.songs?.[0]
|
||||||
@@ -412,7 +549,12 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
const lazy = songs.filter((s) => !s.downloadUrl && s.rawSearch)
|
const lazy = songs.filter((s) => !s.downloadUrl && s.rawSearch)
|
||||||
if (lazy.length > 0) {
|
if (lazy.length > 0) {
|
||||||
try {
|
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)[]
|
songs: (MusicSong | null)[]
|
||||||
}
|
}
|
||||||
// 返回与输入顺序对齐,失败位为 null
|
// 返回与输入顺序对齐,失败位为 null
|
||||||
@@ -479,6 +621,7 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
lyric: opts.lyric,
|
lyric: opts.lyric,
|
||||||
cover: opts.cover,
|
cover: opts.cover,
|
||||||
proxyUrl: opts.proxyUrl,
|
proxyUrl: opts.proxyUrl,
|
||||||
|
qqCookie: currentQqCookie(),
|
||||||
maxConcurrent: opts.maxConcurrent,
|
maxConcurrent: opts.maxConcurrent,
|
||||||
quality
|
quality
|
||||||
})
|
})
|
||||||
@@ -526,17 +669,11 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
}
|
}
|
||||||
const s = settings.value
|
const s = settings.value
|
||||||
if (!s) return
|
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, {
|
await startDownload(task.songsData, {
|
||||||
savedir: s.savedir,
|
savedir: s.savedir,
|
||||||
lyric: s.lyricDownload,
|
lyric: s.lyricDownload,
|
||||||
cover: s.coverDownload,
|
cover: s.coverDownload,
|
||||||
proxyUrl: proxy,
|
proxyUrl: currentProxyUrl(),
|
||||||
engine: s.downloadEngine,
|
engine: s.downloadEngine,
|
||||||
maxConcurrent: s.maxConcurrent,
|
maxConcurrent: s.maxConcurrent,
|
||||||
// 沿用原任务的目标音质("最高" 存为空串),而不是当前默认音质
|
// 沿用原任务的目标音质("最高" 存为空串),而不是当前默认音质
|
||||||
@@ -544,32 +681,59 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 挂上运行时安装 / 更新的进度监听(两个动作共用同一条事件通道) */
|
||||||
|
async function attachRuntimeProgress() {
|
||||||
|
if (progressUnlisten) return
|
||||||
|
progressUnlisten = await listen<MusicInstallProgress>('music-runtime-install-progress', (e) => {
|
||||||
|
installProgress.value = e.payload
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 动作收尾:摘掉监听。失败原因留下面板展示;进度停在最后事件值需清掉,避免误导 */
|
||||||
|
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() {
|
async function installRuntime() {
|
||||||
if (installing.value) return
|
if (installing.value) return
|
||||||
installing.value = true
|
installing.value = true
|
||||||
installError.value = ''
|
installError.value = ''
|
||||||
installProgress.value = null
|
installProgress.value = null
|
||||||
if (!progressUnlisten) {
|
|
||||||
progressUnlisten = await listen<MusicInstallProgress>(
|
|
||||||
'music-runtime-install-progress',
|
|
||||||
(e) => {
|
|
||||||
installProgress.value = e.payload
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
|
await attachRuntimeProgress()
|
||||||
env.value = await commands.musicInstallRuntime()
|
env.value = await commands.musicInstallRuntime()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 保留失败原因供设置页面板展示;进度停在最后事件值,需清掉避免误导
|
detachRuntimeProgress(e)
|
||||||
installError.value = typeof e === 'string' ? e : JSON.stringify(e)
|
|
||||||
throw e
|
throw e
|
||||||
} finally {
|
|
||||||
installing.value = false
|
|
||||||
if (progressUnlisten) {
|
|
||||||
progressUnlisten()
|
|
||||||
progressUnlisten = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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() {
|
function cancelInstall() {
|
||||||
@@ -583,6 +747,9 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
env,
|
env,
|
||||||
|
envLoading,
|
||||||
|
qqCookie,
|
||||||
|
setQqCookie,
|
||||||
availableSources,
|
availableSources,
|
||||||
searching,
|
searching,
|
||||||
searchError,
|
searchError,
|
||||||
@@ -597,10 +764,13 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
tasks,
|
tasks,
|
||||||
init,
|
init,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
|
settingsChanged,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
refreshEnv,
|
refreshEnv,
|
||||||
loadSources,
|
loadSources,
|
||||||
pingBridge,
|
pingBridge,
|
||||||
|
currentProxyUrl,
|
||||||
|
currentQqCookie,
|
||||||
search,
|
search,
|
||||||
parsePlaylist,
|
parsePlaylist,
|
||||||
resolveSong,
|
resolveSong,
|
||||||
@@ -609,6 +779,7 @@ export const useMusicStore = defineStore('music', () => {
|
|||||||
removeTask,
|
removeTask,
|
||||||
redownloadTask,
|
redownloadTask,
|
||||||
installRuntime,
|
installRuntime,
|
||||||
|
updateMusicdl,
|
||||||
cancelInstall,
|
cancelInstall,
|
||||||
stopBridge
|
stopBridge
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 778 KiB |
File diff suppressed because one or more lines are too long
@@ -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)."}
|
|
||||||
Reference in New Issue
Block a user