音乐模块调整

This commit is contained in:
zhongluofeng
2026-09-15 17:17:16 +08:00
parent 4f574cb5fa
commit 8f853f7ef3
31 changed files with 3819 additions and 936 deletions
+305 -66
View File
@@ -10,12 +10,12 @@ Response (one JSON per line on stdout):
Methods:
ping -> {"version", "python"}
env_status -> {"musicdl": version|null}
get_sources -> {"sources": [registered music client names]}
search -> {"results": {source: [song...]}, "sources": [...]}
params: {"keyword", "sources": []}
params: {"keyword", "sources": [], "proxy": url-or-""}
resolve -> {"songs": [resolved-song-or-null, ...], "resolved": n}
params: {"song": {...}} or {"songs": [...]}
可选 "proxy": url-or-""
(懒解析:对搜索结果歌曲执行真实解析链,返回带下载链接
的完整歌曲;输入顺序对齐,失败位为 null)
download -> {"taskId"} (runs in background worker pool)
@@ -62,6 +62,7 @@ import tempfile
import threading
import time
import uuid
from urllib.parse import parse_qs, urlparse
# Fallback sources used when the frontend passes an empty list
DEFAULT_SOURCES = [
@@ -127,6 +128,31 @@ _LAZY_URL = "http://lazy.internal/unresolved"
_PATCHED = False
# 当前命令生效的代理地址("" = 明确不走代理)。
#
# 必须显式控制:requests 未指定 proxies 时会去读**系统代理**(环境变量,以及
# Windows 下 urllib 读取注册表 Internet Settings 里 mihomo 写入的 ProxyEnable/
# ProxyServer)。所以只把「没配置代理」理解为"不传 proxies"是不够的——
# 用户开了代理模块的系统代理后,搜索/解析会被静默送进 mihomo;
# 国内音乐源经境外节点出去就会超时或返回空结果。
_CURRENT_PROXY = ""
@contextlib.contextmanager
def _use_proxy(url):
"""在块内决定所有「未显式指定代理」的 requests 请求怎么走。
`url` 为空串 = 强制直连(禁用系统代理);非空 = 统一走该代理。
显式传了 proxies 的请求(如下载路径)不受影响。
"""
global _CURRENT_PROXY
prev = _CURRENT_PROXY
_CURRENT_PROXY = (url or "").strip()
try:
yield
finally:
_CURRENT_PROXY = prev
def _cap_timeout(timeout, stream):
"""按 stream 与否收紧超时,返回新的 timeout 值。"""
@@ -157,6 +183,16 @@ def _patch_musicdl():
def _capped_request(self, method, url, **kwargs):
kwargs["timeout"] = _cap_timeout(kwargs.get("timeout"), bool(kwargs.get("stream")))
# 未显式指定代理时按当前命令的设置决定(见 _use_proxy)。
# trust_env=False 是为了彻底屏蔽环境变量/系统(注册表)代理:
# 只传 {"http": None} 在 ALL_PROXY 存在时可能仍被代理接管。
if kwargs.get("proxies") is None:
kwargs["proxies"] = (
{"http": _CURRENT_PROXY, "https": _CURRENT_PROXY}
if _CURRENT_PROXY
else {"http": None, "https": None}
)
self.trust_env = False
return _orig_request(self, method, url, **kwargs)
requests.Session.request = _capped_request
@@ -378,12 +414,27 @@ def _resolve_song(src_client, search_result, target=None):
# ---------------------------------------------------------------------------
# 按目标音质解析(取 ≤ 所选的最优档)
#
# musicdl 官方解析按「质量常量」循环、取首个有效档。这里在全局锁保护下临时替换
# 各源质量常量(只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。
# 由于替换的是模块级常量,必须用 _RESOLVE_CAP_LOCK 串行化所有封顶解析,避免并发竞态。
# musicdl 官方解析按「质量常量」循环、取首个有效档。这里临时替换各源的质量常量
# (只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。
# 有损码率目标用 lossless_quality_is_sufficient=False,使官方解析不采纳第三方 flac/hires。
#
# 锁的粒度是**按源**而不是一把全局锁:被替换的是各源自己模块里的常量,
# 不同源之间不会互相干扰;而一把全局锁会把并发下载里所有源的解析串起来
# (每个解析都含网络请求,持锁跨越请求 = 解析阶段吞吐退化成串行)。
# 同一源内必须串行:否则 A 恢复常量时 B 还在解析,B 会拿到未封顶的档位。
# ---------------------------------------------------------------------------
_RESOLVE_CAP_LOCK = threading.Lock()
_RESOLVE_CAP_LOCKS = {}
_RESOLVE_CAP_LOCKS_GUARD = threading.Lock()
def _resolve_cap_lock(source):
"""取该源的封顶解析锁(惰性创建;字典本身受 _RESOLVE_CAP_LOCKS_GUARD 保护)。"""
with _RESOLVE_CAP_LOCKS_GUARD:
lock = _RESOLVE_CAP_LOCKS.get(source)
if lock is None:
lock = threading.Lock()
_RESOLVE_CAP_LOCKS[source] = lock
return lock
# QQSongFileType.SORTED_QUALITIES 前缀(F000=flac, O8xx/O6xx=ogg, M800=320K, M500=128K, C6xx=m4a
_QQ_CAPS = {
@@ -488,7 +539,7 @@ def _resolve_to_quality(src_client, search_result, target):
from musicdl.modules.utils.data import SongInfo
official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1
with _RESOLVE_CAP_LOCK:
with _resolve_cap_lock(source):
_swap_quality_constants(source, allowed)
try:
# 空 song_info_flac + lossless_quality_is_sufficient=False
@@ -511,28 +562,39 @@ _RESOLVE_CLIENTS = {}
_RESOLVE_CLIENTS_LOCK = threading.Lock()
def _get_resolve_client(source):
"""按源缓存的独立源客户端(resolve/试听/批量解析用),失败返回 None。"""
def _get_resolve_client(source, cookies=None):
"""按源+Cookie缓存的独立源客户端(resolve/试听/批量解析用),失败返回 None。"""
cookie_dict = _cookie_str_to_dict(cookies) if source == "QQMusicClient" else None
cache_key = f"{source}|{json.dumps(cookie_dict, sort_keys=True) if cookie_dict else ''}"
with _RESOLVE_CLIENTS_LOCK:
if source in _RESOLVE_CLIENTS:
return _RESOLVE_CLIENTS[source]
if cache_key in _RESOLVE_CLIENTS:
return _RESOLVE_CLIENTS[cache_key]
try:
from musicdl.musicdl import MusicClient
init_cfg = {
source: {
"maintain_session": True,
"max_retries": 1,
"work_dir": _SEARCH_WORK_DIR,
}
}
if cookie_dict:
init_cfg[source].update(
{
"default_search_cookies": cookie_dict,
"default_parse_cookies": cookie_dict,
"default_download_cookies": cookie_dict,
}
)
client = MusicClient(
music_sources=[source],
init_music_clients_cfg={
source: {
"maintain_session": True,
"max_retries": 1,
"work_dir": _SEARCH_WORK_DIR,
}
},
init_music_clients_cfg=init_cfg,
)
src_client = client.music_clients.get(source)
except Exception:
src_client = None
_RESOLVE_CLIENTS[source] = src_client
_RESOLVE_CLIENTS[cache_key] = src_client
return src_client
# Persistent MusicClient cache; keyed by the sorted source list because the
@@ -557,10 +619,31 @@ def _out():
return _PROTOCOL_STDOUT if _PROTOCOL_STDOUT is not None else sys.stdout
def get_client(sources):
"""Return a cached MusicClient, recreating it when the source set changes."""
def _cookie_str_to_dict(value):
"""把浏览器复制的 Cookie 字符串(``k=v; k2=v2``)解析成 dict。
musicdl 的部分接口(如 ``Credential.fromcookiesdict``)只接受 dict
而用户从 DevTools 复制到的是字符串,这里统一转换。已是 dict 时原样返回。
"""
if isinstance(value, dict):
return value
if not isinstance(value, str) or not value.strip():
return None
out = {}
for part in value.split(";"):
if "=" not in part:
continue
k, _, v = part.strip().partition("=")
if k:
out[k] = v
return out or None
def get_client(sources, cookies=None):
"""Return a cached MusicClient, recreating it when the source set or cookies change."""
global _CLIENT, _CLIENT_KEY
key = "|".join(sorted(sources or []))
cookie_dict = _cookie_str_to_dict(cookies)
key = "|".join(sorted(sources or [])) + f"|cookies={json.dumps(cookie_dict, sort_keys=True) if cookie_dict else ''}"
if _CLIENT is None or _CLIENT_KEY != key:
try:
from musicdl.musicdl import MusicClient
@@ -585,6 +668,18 @@ def get_client(sources):
"maintain_session": True,
"max_retries": 1,
}
# 登录态:目前仅 QQ 音乐用得上(解析需登录的歌单 / VIP 音质)。
# 三组 Cookie 都要给:search/lossless 判定用 default_search_cookies(即
# self.default_cookies),歌单解析用 default_parse_cookies,下载用 default_download_cookies。
# 注意 musicdl 的既有逻辑:配置 Cookie 后 QQ 会跳过第三方解析源,全走官方接口。
if cookie_dict and "QQMusicClient" in init_cfg:
init_cfg["QQMusicClient"].update(
{
"default_search_cookies": cookie_dict,
"default_parse_cookies": cookie_dict,
"default_download_cookies": cookie_dict,
}
)
threadings = {src: _SEARCH_SIZE_PER_SOURCE for src in effective}
_CLIENT = MusicClient(
music_sources=effective,
@@ -919,15 +1014,17 @@ def handle_search(params):
if not keyword:
raise ValueError("keyword 不能为空")
sources = params.get("sources") or DEFAULT_SOURCES
client = get_client(sources)
# musicdl 的 rich 进度条写 sys.stdout,必须重定向到 stderr 保持 JSON 通道干净
with _CLIENT_LOCK:
with contextlib.redirect_stdout(sys.stderr):
results = client.search(keyword)
# 懒解析歌批量补查音质档位:QQ 一次批量详情(其余源搜索项自带档位);不逐首解析
for source, songs in results.items():
if source == "QQMusicClient":
_fill_qq_lossless_hints(songs)
# 搜索全程按模块设置决定是否走代理(缺省强制直连,避免被系统代理带走)
with _use_proxy(params.get("proxy")):
client = get_client(sources, params.get("cookies"))
# musicdl 的 rich 进度条写 sys.stdout,必须重定向到 stderr 保持 JSON 通道干净
with _CLIENT_LOCK:
with contextlib.redirect_stdout(sys.stderr):
results = client.search(keyword)
# 懒解析歌批量补查音质档位:QQ 一次批量详情(其余源搜索项自带档位);不逐首解析
for source, songs in results.items():
if source == "QQMusicClient":
_fill_qq_lossless_hints(songs)
# 过滤脏条目:source 接口偶发返回 parsed 失败/字段缺失或跑偏的条目
# (空歌名、"NULL" 歌名/歌手、与关键词毫无关系的无关歌曲),直接剔除
out = {}
@@ -954,22 +1051,164 @@ def handle_get_sources(params):
return {"sources": keys}
_NETEASE_HOSTS = (
"music.163.com",
"y.music.163.com",
"m.music.163.com",
"3g.music.163.com",
"163cn.tv",
)
def _is_netease_url(url: str) -> bool:
try:
host = (urlparse(url).hostname or "").lower()
except Exception:
return False
return any(host == h or host.endswith("." + h) for h in _NETEASE_HOSTS)
def _netease_playlist_id(url: str):
"""从查询串或 "#" 片段里取数字歌单 id(都取不到返回 None)。
注意 "#" 片段常见形态是 ``/playlist?id=NNN``(带路径前缀),
直接 parse_qs 会把键解析成 "/playlist",需先取 "?" 之后的部分。
"""
try:
parts = urlparse(url)
except Exception:
return None
frag = parts.fragment
if "?" in frag:
frag = frag.split("?", 1)[1]
for source in (parts.query, frag):
pid = (parse_qs(source, keep_blank_values=True).get("id") or [None])[0]
if pid and str(pid).isdigit():
return pid
return None
def _normalize_netease_playlist_url(url: str) -> str:
"""把网易云歌单链接规范化成 musicdl 认得出的形式。
musicdl 的网易云 ``parseplaylist`` 只从 URL 的 **"#\" 片段**或**路径末段**取歌单 id,
而「分享/复制链接」出来的地址普遍是 ``.../playlist?id=NNN``id 在查询串里)——
会被当成路径末段 ``playlist`` 去查询,恒返回 0 首。
这里把 id 直接提出来,改写成它认得的 ``https://music.163.com/#/playlist?id=NNN``。
"""
try:
parts = urlparse(url)
except Exception:
return url
host = (parts.hostname or "").lower()
if not any(host == h or host.endswith("." + h) for h in _NETEASE_HOSTS):
return url
for source in (parts.query, parts.fragment):
pid = (parse_qs(source, keep_blank_values=True).get("id") or [None])[0]
if pid and str(pid).isdigit():
return f"https://music.163.com/#/playlist?id={pid}"
return url
def _parse_netease_playlist_lazy(client, url):
"""网易云歌单:懒解析——只取曲目元数据,**全部返回**,下载/试听时再解析链接。
musicdl 的 ``parseplaylist`` 走完整急切解析链,且只保留解析出下载链接的歌,
所以歌单会"少歌"(本例 7 首只剩 2 首)。这里与搜索对齐:
1~2 个请求批量拉元数据(v6 歌单详情 + v3 歌曲详情),直接返回全部曲目;
``rawSearch`` 为歌曲详情原始对象,试听/下载时由 resolve 走真实解析链。
返回 None 表示当前源集合里没有网易云客户端(交回通用路径处理)。
"""
src_client = (client.music_clients or {}).get("NeteaseMusicClient")
extract = _META_EXTRACTORS.get("NeteaseMusicClient")
if src_client is None or extract is None:
return None
playlist_id = _netease_playlist_id(url)
if not playlist_id:
return None
from musicdl.modules.utils import resp2json, safeextractfromdict
try:
playlist_result = resp2json(
resp=src_client.post(
"https://music.163.com/api/v6/playlist/detail", data={"id": playlist_id}
)
)
except Exception as e:
print(f"[playlist] 网易云歌单详情请求失败: {e}", file=sys.stderr)
return []
tracks = safeextractfromdict(playlist_result, ["playlist", "tracks"], []) or []
# v6 响应顶层带 privileges(与曲目一一对应),合入以便无损/音质提示推断
# (与搜索结果同构:_lossless_hint 读 raw_search.privilege.maxbr
for r, p in zip(tracks, playlist_result.get("privileges") or []):
if isinstance(r, dict) and isinstance(p, dict) and p.get("id") in (None, r.get("id")):
r["privilege"] = p
if not tracks:
track_ids = safeextractfromdict(playlist_result, ["playlist", "trackIds"], []) or []
if not track_ids:
return []
# 歌单详情只回 trackIds(裸 id):批量补全元数据(v3 song/detail 支持一次传全部 id
try:
detail = resp2json(
resp=src_client.post(
"https://interface3.music.163.com/api/v3/song/detail",
data={
"c": json.dumps(
[{"id": t.get("id"), "v": 0} for t in track_ids if isinstance(t, dict) and t.get("id")]
)
},
)
)
except Exception as e:
print(f"[playlist] 网易云歌单曲目元数据请求失败: {e}", file=sys.stderr)
return []
tracks = detail.get("songs") or []
# privileges 与 songs 顺序对齐,合入便于音质档位/无损提示推断
for r, p in zip(tracks, detail.get("privileges") or []):
if isinstance(r, dict) and isinstance(p, dict):
r["privilege"] = p
out, seen = [], set()
for r in tracks:
if not isinstance(r, dict) or not r.get("id") or r["id"] in seen:
continue
seen.add(r["id"])
try:
d = song_to_dict(_make_lazy_songinfo(src_client, extract, r))
except Exception as e:
print(f"[playlist] 网易云歌单曲目元数据构造失败: {e}", file=sys.stderr)
continue
# 与搜索同一套脏数据过滤(空歌名 / "NULL" 元数据)
if d.get("songName") and not _meta_garbage(d):
out.append(d)
return out
def handle_parse_playlist(params):
url = (params.get("url") or "").strip()
url = _normalize_netease_playlist_url((params.get("url") or "").strip())
if not url:
raise ValueError("url 不能为空")
sources = params.get("sources") or DEFAULT_SOURCES
client = get_client(sources)
# musicdl 会按源依次尝试解析(第一个成功的源 break),rich 进度写 stdout 需重定向。
# 歌单解析须临时关闭懒解析:网易云歌单接口只回 trackIds(仅 id 无元数据),
# 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接
with _CLIENT_LOCK:
_set_lazy_search(client, False)
try:
with contextlib.redirect_stdout(sys.stderr):
songs = client.parseplaylist(url)
finally:
_set_lazy_search(client, True)
# 与搜索一致:按模块设置决定是否走代理(缺省强制直连)
with _use_proxy(params.get("proxy")):
client = get_client(sources, params.get("cookies"))
# 网易云:懒解析——全部曲目都返回(含元数据),下载/试听时再逐首解析链接
# 与搜索体验一致;musicdl 原生实现只保留解析出链接的歌,会让歌单"少歌"。
lazy_songs = _parse_netease_playlist_lazy(client, url) if _is_netease_url(url) else None
if lazy_songs is not None:
return {"songs": lazy_songs, "count": len(lazy_songs)}
# 其他源沿用 musicdl 的急切解析(QQ 的实现先查查询串取 id,无此问题)。
# musicdl 会按源依次尝试解析(第一个成功的源 break),rich 进度写 stdout 需重定向。
# 歌单解析须临时关闭懒解析:网易云歌单接口只回 trackIds(仅 id 无元数据),
# 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接)
with _CLIENT_LOCK:
_set_lazy_search(client, False)
try:
with contextlib.redirect_stdout(sys.stderr):
songs = client.parseplaylist(url)
finally:
_set_lazy_search(client, True)
# 同搜索:剔除 "NULL" 元数据的脏条目(歌单场景无关键词,不做相关性过滤)
out = [
d
@@ -988,6 +1227,7 @@ def handle_resolve(params):
if not songs:
raise ValueError("songs 不能为空")
target_quality = (params.get("quality") or "").strip()
resolve_cookies = params.get("cookies") or ""
from concurrent.futures import ThreadPoolExecutor
def _resolve_one(song):
@@ -995,7 +1235,7 @@ def handle_resolve(params):
raw_search = song.get("rawSearch")
if not source or not isinstance(raw_search, dict):
return None
src_client = _get_resolve_client(source)
src_client = _get_resolve_client(source, resolve_cookies)
if src_client is None:
return None
# musicdl 的 rich 进度条写 sys.stdout,重定向到 stderr 保持 JSON 通道干净
@@ -1006,7 +1246,10 @@ def handle_resolve(params):
return song_to_dict(resolved)
with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool:
out = list(pool.map(_resolve_one, songs))
# 解析同样要联网:按模块设置决定是否走代理(缺省强制直连)。
# 工作线程读取的是同一个 _CURRENT_PROXY,块内一致。
with _use_proxy(params.get("proxy")):
out = list(pool.map(_resolve_one, songs))
return {"songs": out, "resolved": sum(1 for r in out if r)}
@@ -1017,6 +1260,7 @@ def handle_download(params):
lyric = bool(params.get("lyric", True))
cover = bool(params.get("cover", True))
proxy_url = params.get("proxy") or ""
cookies = params.get("cookies") or ""
max_concurrent = max(1, min(int(params.get("maxConcurrent") or 1), 16))
target_quality = (params.get("quality") or "").strip()
if not songs:
@@ -1026,7 +1270,7 @@ def handle_download(params):
_TASKS[task_id] = state
thread = threading.Thread(
target=_download_supervisor,
args=(task_id, songs, savedir, lyric, cover, proxy_url, state, max_concurrent, target_quality),
args=(task_id, songs, savedir, lyric, cover, proxy_url, cookies, state, max_concurrent, target_quality),
daemon=True,
)
thread.start()
@@ -1041,7 +1285,7 @@ def handle_cancel(params):
return {"taskId": task_id, "cancelled": False}
def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, state, max_concurrent, target_quality=""):
def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, cookies, state, max_concurrent, target_quality=""):
"""下载监督线程:按 maxConcurrent 启动工作线程,逐首领取歌曲下载。
每个工作线程持有独立 MusicClient(互不共享、不与搜索客户端争锁),
因此搜索期间下载照常推进;取消为队列级(当前歌曲完成,其余标记取消)。"""
@@ -1062,11 +1306,22 @@ def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, state
# maintain_session=True 复用连接(默认每请求新建 Session);
# max_retries=1 封顶死链重试(下载前链接均已验证)
sources_list = sources or DEFAULT_SOURCES
worker_cfg = {
src: {"maintain_session": True, "max_retries": 1} for src in sources_list
}
# QQ 音乐 CookieVIP 音质 / 官方 vkey 接口)
cookie_dict = _cookie_str_to_dict(cookies)
if cookie_dict and "QQMusicClient" in worker_cfg:
worker_cfg["QQMusicClient"].update(
{
"default_search_cookies": cookie_dict,
"default_parse_cookies": cookie_dict,
"default_download_cookies": cookie_dict,
}
)
client = MusicClient(
music_sources=sources_list,
init_music_clients_cfg={
src: {"maintain_session": True, "max_retries": 1} for src in sources_list
},
init_music_clients_cfg=worker_cfg,
)
except Exception as exc:
with lock:
@@ -1303,8 +1558,6 @@ def handle(method, params):
"version": "0.1.0",
"python": sys.version.split()[0],
}
if method == "env_status":
return check_env()
if method == "get_sources":
return handle_get_sources(params)
if method == "parse_playlist":
@@ -1320,20 +1573,6 @@ def handle(method, params):
raise ValueError("unknown method: %s" % method)
def check_env():
"""Report whether musicdl is importable and its version."""
result = {"musicdl": None}
try:
import musicdl
result["musicdl"] = getattr(musicdl, "__version__", "unknown")
except Exception as exc:
# Not installed / broken deps: report None so the frontend can prompt
# the user to install the runtime first.
sys.stderr.write("musicdl import failed: %s\n" % exc)
return result
def main():
global _PROTOCOL_STDOUT
# 编码对齐:Rust 侧以 UTF-8 字节写入 stdinserde_json 序列化不转义非 ASCII),