Files
Thing/src-tauri/src/music/bridge.py
T
2026-09-15 17:17:16 +08:00

1617 lines
69 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""musicdl bridge process: stdio JSON-Lines protocol.
Request (one JSON per line on stdin):
{"id": 1, "method": "ping", "params": {...}}
Response (one JSON per line on stdout):
{"id": 1, "ok": true, "result": {...}}
{"id": 1, "ok": false, "error": "..."}
Methods:
ping -> {"version", "python"}
get_sources -> {"sources": [registered music client names]}
search -> {"results": {source: [song...]}, "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)
params: {"taskId", "songs": [song dicts], "savedir",
"lyric", "cover", "proxy": url-or-"",
"maxConcurrent": n}
cancel -> {"taskId": true}
params: {"taskId"} (queue-level cancel)
Events (no "id", forwarded verbatim to the frontend by the Rust side):
{"event":"download","taskId":T,"type":"resolving","key":K,"songName":S,"singers":G}
{"event":"download","taskId":T,"type":"start","key":K,"songName":S,"singers":G,"ext":E,"total":N}
{"event":"download","taskId":T,"type":"progress","key":K,"downloaded":N,"total":N}
{"event":"download","taskId":T,"type":"done","key":K}
{"event":"download","taskId":T,"type":"error","key":K,"message":M}
{"event":"download","taskId":T,"type":"cancelled","key":K}
{"event":"download","taskId":T,"type":"finished","done":N,"total":M}
Protocol notes:
- stdin must be UTF-8 (Rust writes raw UTF-8 bytes, serde_json does not escape
non-ASCII); main() reconfigures stdin/stdout to UTF-8 because Windows pipes
otherwise default to the locale codec (GBK on Chinese systems).
- stdout is parsed by Rust as UTF-8; all responses use ensure_ascii=True
(pure ASCII, immune to locale/GBK encoding issues).
- Event lines from background threads are written under a lock to avoid
interleaving partial JSON lines.
- musicdl's rich progress bars write to sys.stdout and would corrupt the
JSON channel, so search/download bodies run under redirect_stdout(stderr).
- Download progress is reported by polling each song's target file size
(musicdl writes directly to song.save_path, no temp files).
Embedded via include_str! in src-tauri/src/music/bridge.rs; keep stdlib-only
(no third-party imports at module level - musicdl is imported lazily so the
bridge can start without it).
"""
import contextlib
import hashlib
import json
import os
import re
import sys
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 = [
"NeteaseMusicClient",
"QQMusicClient",
"KugouMusicClient",
]
# ---------------------------------------------------------------------------
# musicdl 运行时补丁(monkey-patch,仅影响本桥接进程;musicdl 锁定 2.13.11
#
# 背景:musicdl 的 search = 官方搜索 + 逐首解析下载链接。每个源的解析链按
# l1→l4 顺序串行尝试最多 15+ 个第三方 API(单个超时 10~30s),且一页内的
# 候选歌是串行解析的。大量第三方 API 已死或不稳定:
# - 慢:死 API 的超时被逐个吃满,一次搜索动辄 1-2 分钟;
# - 错:l3/l4「不稳定账号」API 会返回无关歌曲/空元数据(legalizestring(None)
# 产出字面量 "NULL"),即搜索结果里 NULL 歌名、错误歌手的来源。
# 补丁:1) HTTP 超时封顶;2) 修剪解析链,只保留 l1/l2 头部可靠源;
# 3) 链接探测复用会话;4) 会话连接复用 + 限重试(init cfg,见 get_client);
# 5) 懒解析:搜索只取元数据,解析链推迟到下载/试听(见 _apply_lazy_search_patch)。
# ---------------------------------------------------------------------------
# 非流式请求(搜索/解析/歌词等 JSON API)的超时上限 (connect, read)
_TIMEOUT_CAP = (3, 8)
# 流式请求(真实下载/链接探测)仅收紧连接超时,读取超时保持原值
_STREAM_CONNECT_CAP = 3
# 每源并行分页数:懒解析源(有 _META_EXTRACTORS 提取器)搜索 = 每源 1 个请求
# (单页 10 条结果,本地提取元数据);旧的 per_page=1 × 10 页并行是为绕过页内
# 逐首串行解析设计的,且 10 个并行请求会触发 QQ 搜索接口限速(~9s/间歇全失败)。
# 无提取器的源(酷我/咪咕等,仍急切解析)继续用 per_page=1 × 多页并行。
_SEARCH_SIZE_PER_SOURCE = 10
# 修剪后的第三方解析链(保持 musicdl 原 l1/l2 顺序;未列出的源不修剪)
_PRUNED_THIRDPARTY_PARSERS = {
"QQMusicClient": (
"_parsewithvkeysapi",
"_parsewithxcvtsapi",
"_parsewithchkszapi",
),
"NeteaseMusicClient": (
"_parsewithtmetuapi",
"_parsewithxuanluogeapi",
"_parsewithkangqiovoapi",
"_parsewithchkszapi",
"_parsewithznnuapi",
"_parsewithxiaoqinapi",
),
"KugouMusicClient": (
"_parsewithbakaapi",
"_parsewithqqovoapi",
"_parsewithlzmhhhapi",
"_parsewithxianyuwapi",
),
}
# 搜索缓存 pkl 的落盘目录(musicdl 会往 work_dir 写 search_results.pkl,对桥接无用)
_SEARCH_WORK_DIR = os.path.join(tempfile.gettempdir(), "musicdl-search")
# 懒解析占位 URLmusicdl 的 with_valid_download_url 要求 download_url 以 http 开头,
# 未解析条目用它标记(song_to_dict 输出时会置空 downloadUrl 并附带 rawSearch
_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 值。"""
if stream:
if isinstance(timeout, tuple):
return (min(timeout[0], _STREAM_CONNECT_CAP), timeout[1])
if isinstance(timeout, (int, float)):
return (_STREAM_CONNECT_CAP, timeout)
return (_STREAM_CONNECT_CAP, 30)
if isinstance(timeout, tuple):
return (min(timeout[0], _TIMEOUT_CAP[0]), min(timeout[1], _TIMEOUT_CAP[1]))
if isinstance(timeout, (int, float)):
return (_TIMEOUT_CAP[0], min(timeout, _TIMEOUT_CAP[1]))
return _TIMEOUT_CAP
def _patch_musicdl():
"""对已安装的 musicdl 应用性能/正确性补丁(幂等,首次 import 后调用)。"""
global _PATCHED
if _PATCHED:
return
_PATCHED = True
import requests
# --- 1. 超时封顶:所有请求(含各 source 模块级 requests.get/post 与
# AudioLinkTester 的 session)都经由 requests.Session.request,在此统一收紧
_orig_request = requests.Session.request
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
# --- 3. 链接探测复用会话:AudioLinkTester.test 默认 renew_session=True
# 每次探测前先 close 旧 Session 再新建(多付一次 TCP+TLS 握手)。
# 各源 officialapi 解析每首歌至少探测一次,改为复用可叠加提速
from musicdl.modules.sources.kugou import KugouMusicClient
from musicdl.modules.sources.netease import NeteaseMusicClient
from musicdl.modules.sources.qq import QQMusicClient
from musicdl.modules.utils.data import SongInfo
from musicdl.modules.utils.misc import AudioLinkTester
_orig_test = AudioLinkTester.test
def _reused_session_test(self, url, request_overrides=None, renew_session=True):
return _orig_test(self, url, request_overrides=request_overrides, renew_session=False)
AudioLinkTester.test = _reused_session_test
# --- 2. 修剪第三方解析链:跳过 l3/l4 不稳定账号 API
def _make_pruned_thirdpartapis(parsers):
def _parsewiththirdpartapis(self, search_result, request_overrides=None):
request_overrides = request_overrides or {}
if self.default_cookies or request_overrides.get("cookies"):
return SongInfo(source=self.source)
for name in parsers:
song_info = SongInfo(
source=self.source,
raw_data={"search": search_result, "download": {}, "lyric": {}},
)
with contextlib.suppress(Exception):
song_info = getattr(self, name)(search_result, request_overrides)
if song_info.with_valid_download_url and song_info.ext in AudioLinkTester.VALID_AUDIO_EXTS:
return song_info
return SongInfo(source=self.source)
return _parsewiththirdpartapis
for _cls in (QQMusicClient, NeteaseMusicClient, KugouMusicClient):
_parsers = _PRUNED_THIRDPARTY_PARSERS.get(_cls.source)
if _parsers:
_cls._parsewiththirdpartapis = _make_pruned_thirdpartapis(_parsers)
# ---------------------------------------------------------------------------
# 懒解析(lazy resolve
#
# musicdl 把「搜索元数据」和「解析下载链接」绑死在 search() 里:每首歌都要跑
# 完整解析链(第三方 l1/l2 + 官方音质阶梯 + 链接探测,1~3s/首)才进结果列表。
# 懒解析把两者拆开:搜索阶段只调官方搜索 API 提取元数据(每页 1 个请求),
# 下载/试听时才对被选中的歌执行真实解析链。
#
# 机制:对「搜索客户端」的各源实例做实例级方法覆盖(_parsewiththirdpartapis /
# _parsewithofficialapiv1 换成纯元数据版本),类方法不动——因此下载 worker、
# resolve 专用客户端(全新实例)走的仍是真实解析链。
# 无提取器的源(酷我/咪咕等)不打补丁,保持原急切解析(有 URL,行为不变)。
# 注意:歌单解析(parse_playlist)会临时关掉懒解析——网易云歌单接口只回
# trackIds(仅 id 无元数据),懒元数据提取会得到空结果。
# 元数据提取器的字段映射复制自 musicdl 2.13.11 各源 _parsewithofficialapiv1
# 的 SongInfo 构造,升级 musicdl 版本时需同步核对(与 _PRUNED 列表一起)。
# ---------------------------------------------------------------------------
def _extract_netease_meta(r):
from musicdl.modules.utils import SongInfoUtils, legalizestring, safeextractfromdict
duration_s = float(r.get("dt", 0) or 0) / 1000
return {
"song_name": legalizestring(r.get("name")),
"singers": legalizestring(
", ".join(
[s.get("name") for s in (safeextractfromdict(r, ["ar"], []) or []) if isinstance(s, dict) and s.get("name")]
)
),
"album": legalizestring(safeextractfromdict(r, ["al", "name"], None)),
"duration_s": duration_s,
"duration": SongInfoUtils.seconds2hms(duration_s),
"cover_url": safeextractfromdict(r, ["al", "picUrl"], None),
"identifier": r.get("id"),
}
def _extract_qq_meta(r):
from musicdl.modules.utils import SongInfoUtils, legalizestring, safeextractfromdict
duration_s = int(float(r.get("interval", 0) or 0))
return {
"song_name": legalizestring(r.get("title") or r.get("songname")),
"singers": legalizestring(
", ".join([s.get("name") for s in (r.get("singer", []) or []) if isinstance(s, dict) and s.get("name")])
),
"album": legalizestring(safeextractfromdict(r, ["album", "title"], None) or r.get("albumname")),
"duration_s": duration_s,
"duration": SongInfoUtils.seconds2hms(duration_s),
"cover_url": "https://y.gtimg.cn/music/photo_new/T002R800x800M000{}.jpg".format(
safeextractfromdict(r, ["album", "mid"], "") or r.get("albummid")
),
"identifier": r.get("mid") or r.get("songmid"),
}
def _extract_kugou_meta(r):
from musicdl.modules.utils import SongInfoUtils, legalizestring, safeextractfromdict
duration_s = float(r.get("duration", 0) or r.get("Duration", 0) or 0) or (float(r.get("timelen", 0) or 0) / 1000)
cover = safeextractfromdict(r, ["trans_param", "union_cover"], None) or r.get("cover_url") or r.get("Image")
if cover and isinstance(cover, str) and "{size}" in cover:
cover = cover.format(size=300)
return {
"song_name": legalizestring(
r.get("songname")
or r.get("SongName")
or r.get("songname_original")
or r.get("OriSongName")
or r.get("filename")
or r.get("FileName")
or r.get("name")
or r.get("Name")
),
"singers": legalizestring(
r.get("singername")
or r.get("SingerName")
or ", ".join(
[s.get("name") for s in (r.get("singerinfo") or r.get("Singers") or []) if isinstance(s, dict) and s.get("name")]
)
),
"album": legalizestring(r.get("album_name") or r.get("AlbumName") or safeextractfromdict(r, ["albuminfo", "name"], None)),
"duration_s": duration_s,
"duration": SongInfoUtils.seconds2hms(duration_s),
"cover_url": cover,
"identifier": r.get("hash") or r.get("FileHash"),
}
_META_EXTRACTORS = {
"NeteaseMusicClient": _extract_netease_meta,
"QQMusicClient": _extract_qq_meta,
"KugouMusicClient": _extract_kugou_meta,
}
def _make_lazy_songinfo(src_client, extract, search_result):
"""懒解析 SongInfo:仅元数据 + 占位 download_urlwith_valid_download_url 要求 http 开头)。"""
from musicdl.modules.utils.data import SongInfo
try:
meta = extract(search_result) or {}
except Exception:
meta = {}
identifier = meta.pop("identifier", None)
if identifier in (None, "", 0):
# identifier 缺失会被 _removeduplicates 全部合并成一首,用内容哈希兜底
identifier = hashlib.md5(
json.dumps(search_result, sort_keys=True, default=str).encode("utf-8")
).hexdigest()[:12]
return SongInfo(
raw_data={"search": search_result, "download": {}, "lyric": {}},
source=src_client.source,
identifier=str(identifier),
download_url=_LAZY_URL,
download_url_status={"ok": True, "lazy": True},
**meta,
)
def _apply_lazy_search_patch(client):
"""对搜索客户端的各源实例打懒解析补丁(实例级覆盖,只影响本客户端)。
原始解析方法保存为 _real_*,供 _resolve_song / 歌单急切解析复用。"""
for source, src_client in client.music_clients.items():
extract = _META_EXTRACTORS.get(source)
if extract is None:
continue
src_client._real_parsewiththirdpartapis = src_client._parsewiththirdpartapis
src_client._real_parsewithofficialapiv1 = src_client._parsewithofficialapiv1
def _lazy_thirdparty(search_result, request_overrides=None, _c=src_client, _e=extract):
return _make_lazy_songinfo(_c, _e, search_result)
def _lazy_official(
search_result, song_info_flac=None, lossless_quality_is_sufficient=True,
lossless_quality_definitions=None, request_overrides=None, _c=src_client, _e=extract,
):
return song_info_flac if song_info_flac is not None else _make_lazy_songinfo(_c, _e, search_result)
src_client._lazy_thirdparty = _lazy_thirdparty
src_client._lazy_official = _lazy_official
src_client._parsewiththirdpartapis = _lazy_thirdparty
src_client._parsewithofficialapiv1 = _lazy_official
def _set_lazy_search(client, enabled):
"""切换搜索客户端的懒解析开关(须持有 _CLIENT_LOCK;歌单解析前临时关闭)。"""
for src_client in client.music_clients.values():
if not hasattr(src_client, "_lazy_thirdparty"):
continue
if enabled:
src_client._parsewiththirdpartapis = src_client._lazy_thirdparty
src_client._parsewithofficialapiv1 = src_client._lazy_official
else:
src_client._parsewiththirdpartapis = src_client._real_parsewiththirdpartapis
src_client._parsewithofficialapiv1 = src_client._real_parsewithofficialapiv1
def _resolve_song(src_client, search_result, target=None):
"""执行真实解析链(第三方 + 官方音质阶梯),返回 SongInfo(可能无有效链接)。
src_client 为懒解析搜索客户端时用 _real_* 引用,全新客户端则类方法即真实链。
target 为音质 label""/"最高"/"Hi-Res" 取最高);其余按「≤所选最优档」封顶解析。"""
target = (target or "").strip()
if target and target != "最高":
return _resolve_to_quality(src_client, search_result, target)
thirdparty = getattr(src_client, "_real_parsewiththirdpartapis", None) or src_client._parsewiththirdpartapis
official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1
song_info_flac = thirdparty(search_result=search_result)
song_info = official(search_result=search_result, song_info_flac=song_info_flac)
return song_info if song_info.with_valid_download_url else song_info_flac
# ---------------------------------------------------------------------------
# 按目标音质解析(取 ≤ 所选的最优档)
#
# musicdl 官方解析按「质量常量」循环、取首个有效档。这里临时替换各源的质量常量
# (只保留 ≤ 目标档位),让官方解析只循环这些档位;解析完成后立即恢复。
# 有损码率目标用 lossless_quality_is_sufficient=False,使官方解析不采纳第三方 flac/hires。
#
# 锁的粒度是**按源**而不是一把全局锁:被替换的是各源自己模块里的常量,
# 不同源之间不会互相干扰;而一把全局锁会把并发下载里所有源的解析串起来
# (每个解析都含网络请求,持锁跨越请求 = 解析阶段吞吐退化成串行)。
# 同一源内必须串行:否则 A 恢复常量时 B 还在解析,B 会拿到未封顶的档位。
# ---------------------------------------------------------------------------
_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 = {
"Hi-Res": None, # 全部
"无损": {"F000", "O801", "O800", "O600", "O400", "M800", "M500", "C600", "C400", "C200"},
"320K": {"M800", "M500", "C600", "C400", "C200"},
"192K": {"M500", "C600", "C400", "C200"},
"128K": {"M500", "C200"},
"96K": {"C200"},
}
# 网易:MUSIC_QUALITIESlossless=flac, exhigh=320K, standard=128K
_NETEASE_CAPS = {
"Hi-Res": None,
"无损": {"lossless", "dolby", "exhigh", "standard"},
"320K": {"exhigh", "standard"},
"192K": {"standard"},
"128K": {"standard"},
"96K": {"standard"},
}
# 酷狗:MUSIC_QUALITIESflac=无损, high≈320K, 320, 128
_KUGOU_CAPS = {
"Hi-Res": None,
"无损": {"flac", "high", "320", "128"},
"320K": {"high", "320", "128"},
"192K": {"128"},
"128K": {"128"},
"96K": {"128"},
}
class _ValueList:
"""包装为 .value 的假 listQQ SongFileType.SORTED_QUALITIES.value 形态)。"""
def __init__(self, items):
self._items = items
@property
def value(self):
return self._items
class _QQSongFileTypeProxy:
"""代理 QQ SongFileType,仅替换 SORTED_QUALITIES.value,其余属性转发真实枚举。"""
def __init__(self, capped_tuples, real):
self._capped = _ValueList(capped_tuples)
self._real = real
@property
def SORTED_QUALITIES(self):
return self._capped
def __getattr__(self, name):
return getattr(self._real, name)
_QUALITY_CONST_BACKUP = {}
def _swap_quality_constants(source, allowed):
"""临时把源的质量常量换成「只含 ≤ 目标档位」的列表;真正原值存入备份用于恢复。"""
import importlib
if source == "QQMusicClient":
import musicdl.modules.sources.qq as m
real = m.SongFileType
capped = [(p, e) for (p, e) in real.SORTED_QUALITIES.value if p in allowed]
m.SongFileType = _QQSongFileTypeProxy(capped, real)
_QUALITY_CONST_BACKUP[source] = (m, "SongFileType", real)
return
modname = {"NeteaseMusicClient": "netease", "KugouMusicClient": "kugou"}[source]
m = importlib.import_module("musicdl.modules.sources." + modname)
real = m.MUSIC_QUALITIES
m.MUSIC_QUALITIES = [q for q in real if q in allowed]
_QUALITY_CONST_BACKUP[source] = (m, "MUSIC_QUALITIES", real)
def _restore_quality_constants(source):
entry = _QUALITY_CONST_BACKUP.pop(source, None)
if entry is None:
return
mod, attr, original = entry
setattr(mod, attr, original)
def _resolve_to_quality(src_client, search_result, target):
"""按目标音质解析(≤目标最优档);无 ≤目标 档位时返回无有效链接。
无损级目标(Hi-Res/无损/最高)直接取最高(本就 ≤ 无损);有损码率目标封顶官方阶梯,
QQ 等免费源依赖第三方解析(只回最佳档)时封顶拿不到,遂回退取最高保证能下载。"""
source = src_client.source
if source == "QQMusicClient":
allowed = _QQ_CAPS.get(target)
elif source == "NeteaseMusicClient":
allowed = _NETEASE_CAPS.get(target)
elif source == "KugouMusicClient":
allowed = _KUGOU_CAPS.get(target)
else:
allowed = None
if allowed is None or target in ("Hi-Res", "无损"):
return _resolve_song(src_client, search_result) # 最高/无损级 → 原最高路径
from musicdl.modules.utils.data import SongInfo
official = getattr(src_client, "_real_parsewithofficialapiv1", None) or src_client._parsewithofficialapiv1
with _resolve_cap_lock(source):
_swap_quality_constants(source, allowed)
try:
# 空 song_info_flac + lossless_quality_is_sufficient=False
# 官方解析不采纳第三方 flac/hires,只按封顶后的官方阶梯取 ≤target 档
song_info = official(
search_result=search_result,
song_info_flac=SongInfo(source=source),
lossless_quality_is_sufficient=False,
)
finally:
_restore_quality_constants(source)
if song_info.with_valid_download_url:
return song_info
# 封顶拿不到(如 QQ 免费源只能即时解析链取最佳档)→ 回退取最高,保证能下载
return _resolve_song(src_client, search_result)
# resolve 专用客户端缓存(按源;不打懒解析补丁,maintain_session 复用连接)
_RESOLVE_CLIENTS = {}
_RESOLVE_CLIENTS_LOCK = threading.Lock()
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 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=init_cfg,
)
src_client = client.music_clients.get(source)
except Exception:
src_client = None
_RESOLVE_CLIENTS[cache_key] = src_client
return src_client
# Persistent MusicClient cache; keyed by the sorted source list because the
# client is constructed with a fixed source set (re-created on change).
_CLIENT = None
_CLIENT_KEY = None
# Serialize access to the shared MusicClient (search vs download threads).
_CLIENT_LOCK = threading.Lock()
# Serialize stdout writes (response line + background event lines).
_WRITE_LOCK = threading.Lock()
# Active download tasks: taskId -> {"cancel": bool}
_TASKS = {}
# The REAL stdout captured at startup. musicdl 的 rich 进度条通过 redirect_stdout
# 把 sys.stdout 全局重定向到 stderr(跨线程生效),因此协议写入(响应/事件)必须
# 走这个捕获的引用,而不是 sys.stdout,否则会一并写进 stderr 丢失。
_PROTOCOL_STDOUT = None
def _out():
"""返回真实 stdout(协议写入用)"""
return _PROTOCOL_STDOUT if _PROTOCOL_STDOUT is not None else sys.stdout
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
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
from musicdl.modules.utils.data import SongInfo
except ImportError:
raise RuntimeError("musicdl 未安装,请先在设置页安装环境")
_patch_musicdl()
effective = sources or DEFAULT_SOURCES
# 懒解析源(有元数据提取器):单页 10 条 = 每源 1 个请求,本地提取元数据;
# 急切解析源(无提取器):per_page=1 × 10 页并行,避免页内逐首串行解析。
# maintain_session=True:默认 False 时每个 get/post 都 _initsession() 新建
# Session(零 TCP/TLS 连接复用,每请求多付 100~300ms 握手);
# max_retries=1:默认 3 会让死 API 最坏吃满 3 轮超时。
# 搜索缓存 pkl 落临时目录。
init_cfg = {}
for src in effective:
lazy = src in _META_EXTRACTORS
init_cfg[src] = {
"search_size_per_source": _SEARCH_SIZE_PER_SOURCE,
"search_size_per_page": _SEARCH_SIZE_PER_SOURCE if lazy else 1,
"work_dir": _SEARCH_WORK_DIR,
"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,
init_music_clients_cfg=init_cfg,
clients_threadings=threadings,
)
# 懒解析:搜索阶段只取元数据(实例级补丁,下载/resolve 客户端不受影响)
_apply_lazy_search_patch(_CLIENT)
_CLIENT_KEY = key
return _CLIENT
def _emit_event(event):
"""写一条事件行到真实 stdout(加锁防交错)"""
line = json.dumps(event, ensure_ascii=True)
with _WRITE_LOCK:
_out().write(line + "\n")
_out().flush()
# 无损扩展名(与前端 LOSSLESS_EXTS 保持一致;musicdl 的 ext 无前导点)
_LOSSLESS_EXTS = {"flac", "wav", "alac", "ape", "wv", "tta", "dsf", "dff"}
# 网易 privilege.maxbr 无损门槛(bps):128k/192k/320k 为有损,flac/hires 为 999000
_NETEASE_LOSSLESS_MAXBR = 740000
def _to_num(v):
try:
return float(v)
except (TypeError, ValueError):
return 0.0
def _lossless_hint(s):
"""推断歌曲是否具备无损音质。急切解析歌看 ext;懒解析歌从 raw_search 的
官方音质字段推断;无法推断返回 None(表示未知)。"""
ext = str(getattr(s, "ext", None) or "").strip().lower().lstrip(".")
if ext:
return ext in _LOSSLESS_EXTS
rs = (getattr(s, "raw_data", None) or {}).get("search") if isinstance(getattr(s, "raw_data", None), dict) else None
if not isinstance(rs, dict):
return None
# QQ:搜索结果无音质字段,用 handle_search 的批量详情补查结果
if "_qq_lossless" in rs:
return bool(rs["_qq_lossless"])
# 酷狗:SQflac/ Reshires)文件大小
if "SQFileSize" in rs or "ResFileSize" in rs:
return _to_num(rs.get("SQFileSize")) > 0 or _to_num(rs.get("ResFileSize")) > 0
# 网易:privilege.maxbr 为该账号可用的最高码率
priv = rs.get("privilege")
if isinstance(priv, dict) and "maxbr" in priv:
return _to_num(priv.get("maxbr")) >= _NETEASE_LOSSLESS_MAXBR
return None
def _fill_qq_lossless_hints(songs, batch_size=10):
"""QQ 懒解析歌批量补查音质提示:musicu.fcg 一次 POST 查多首详情
(搜索 API 的 item_song 不含音质/大小字段)。结果写入 raw_search
的 _qq_losslessbool);失败静默(保持未知)。"""
lazy = [
s for s in songs
if getattr(s, "download_url", None) == _LAZY_URL
and isinstance(getattr(s, "raw_data", None), dict)
and isinstance(s.raw_data.get("search"), dict)
and s.identifier
]
for start in range(0, len(lazy), batch_size):
batch = lazy[start:start + batch_size]
payload = {
"songinfo%d" % i: {
"method": "get_song_detail_yqq",
"module": "music.pf_song_detail_svr",
"param": {"song_mid": s.identifier},
}
for i, s in enumerate(batch)
}
try:
import requests
resp = requests.post(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
json=payload,
headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"},
timeout=(3, 8),
)
resp.raise_for_status()
data = resp.json()
except Exception:
return # 本次搜索放弃补查(不影响结果,仅音质提示缺失)
for i, s in enumerate(batch):
try:
info = data.get("songinfo%d" % i) or {}
track = (info.get("data") or {}).get("track_info") or {}
f = track.get("file") or {}
s.raw_data["search"]["_qq_lossless"] = (
_to_num(f.get("size_flac")) > 0 or _to_num(f.get("size_hires")) > 0
)
# 保留完整 file 对象,供 _quality_tiers_qq 组装全音质档位(含各档大小)
s.raw_data["search"]["_qq_file"] = f
except Exception:
continue
# ---------------------------------------------------------------------------
# 搜索阶段音质档位(格式 + 大小 + 无损):懒解析歌从各源官方搜索接口直接组装,
# 不跑解析链、不额外请求。统一档位表示 {label, ext, bitrate, sizeBytes, size, lossless}
# 其中 label 为统一音质类型词表(Hi-Res / 无损 / {K}K / AAC),前端据此统一渲染。
# ---------------------------------------------------------------------------
def _fmt_size(bytes_):
"""字节数 → 人类可读大小(<1MB 用 KB,否则用一位小数 MB);无效返回 None。"""
try:
n = _to_num(bytes_)
except Exception:
n = 0.0
if n <= 0:
return None
if n < 1048576:
return "%.0f KB" % (n / 1024)
return "%.1f MB" % (n / 1048576)
def _quality(label, ext, size_bytes, bitrate=None):
"""构造单个档位 dict。label=统一类型词表;bitrate 统一为 kbps 整数;无损按 ext 判定。"""
return {
"label": label,
"ext": ext,
"bitrate": bitrate,
"sizeBytes": int(_to_num(size_bytes)) if _to_num(size_bytes) > 0 else None,
"size": _fmt_size(size_bytes),
"lossless": (ext or "").lower().lstrip(".") in _LOSSLESS_EXTS,
}
# QQmusicu.fcg 详情返回的 file 各音质大小键 → (标签, ext)。自上而下为显示顺序(高→低)
_QQ_SIZE_KEYS = (
("size_hires", "Hi-Res", "flac"),
("size_flac", "无损", "flac"),
("size_320mp3", "320K", "mp3"),
("size_192aac", "192K", "aac"),
("size_192ogg", "192K", "ogg"),
("size_128mp3", "128K", "mp3"),
("size_96aac", "96K", "aac"),
)
def _quality_tiers_qq(s):
rs = (getattr(s, "raw_data", None) or {}).get("search")
f = (rs or {}).get("_qq_file")
if not isinstance(f, dict):
return None
tiers = []
for key, label, ext in _QQ_SIZE_KEYS:
size = _to_num(f.get(key, 0))
if size <= 0:
continue
# 同类档位取已有最大体积的(如 192K 在 aac/ogg 都可用时只留一条)
if tiers and tiers[-1]["label"] == label:
if size > (tiers[-1].get("sizeBytes") or 0):
tiers[-1] = _quality(label, ext, size, _QQ_LABEL_BITRATE.get(label))
continue
tiers.append(_quality(label, ext, size, _QQ_LABEL_BITRATE.get(label)))
return tiers or None
_QQ_LABEL_BITRATE = {"320K": 320, "192K": 192, "128K": 128, "96K": 96}
def _quality_tiers_netease(s):
"""网易云搜索项自带小写 h/m/l/sq 质量对象(各含 br/size),无需详情接口。"""
rs = (getattr(s, "raw_data", None) or {}).get("search")
if not isinstance(rs, dict):
return None
tiers = []
# sq=无损/Hi-Resflac
sq = rs.get("sq")
if isinstance(sq, dict) and _to_num(sq.get("size")) > 0:
br = int(_to_num(sq.get("br"))) or None
label = "Hi-Res" if (br or 0) >= 900000 else "无损"
tiers.append(_quality(label, "flac", sq.get("size"), (br // 1000) if br else None))
# h/m/l = 320K/192K/128Kmp3/aac
for key, label, ext in (("h", "320K", "mp3"), ("m", "192K", "mp3"), ("l", "128K", "mp3")):
obj = rs.get(key)
if isinstance(obj, dict) and _to_num(obj.get("size")) > 0:
tiers.append(_quality(label, ext, obj.get("size"), int(_to_num(obj.get("br"))) // 1000 or None))
return tiers or None
def _quality_tiers_kugou(s):
"""酷狗搜索项自带各音质大小(Bitrate 等为 kbps),无需额外请求。
档位高→低:Hi-Res(Res) / 无损(SQ) / 320K(HQ) / {Bitrate}K(FileSize)。"""
rs = (getattr(s, "raw_data", None) or {}).get("search")
if not isinstance(rs, dict):
return None
heads = (
("ResFileSize", "ResExtName", "Hi-Res", "flac", "ResBitrate"),
("SQFileSize", "SQExtName", "无损", "flac", "SQBitrate"),
("HQFileSize", "HQExtName", "320K", None, "HQBitrate"),
("FileSize", "ExtName", None, None, "Bitrate"),
)
tiers = []
for size_key, ext_key, fixed_label, fixed_ext, br_key in heads:
size = _to_num(rs.get(size_key))
if size <= 0:
continue
bitrate = int(_to_num(rs.get(br_key))) or None
ext = (str(rs.get(ext_key) or "") or "").lower().lstrip(".")
if fixed_ext:
ext = ext or fixed_ext
if fixed_label:
label = fixed_label
elif bitrate:
label = "%dK" % bitrate
elif ext:
label = (ext or "").upper()
else:
label = "标准"
tiers.append(_quality(label, ext or "mp3", size, bitrate))
return tiers or None
_QUALITY_TIER_FN = {
"QQMusicClient": _quality_tiers_qq,
"NeteaseMusicClient": _quality_tiers_netease,
"KugouMusicClient": _quality_tiers_kugou,
}
def _quality_tiers(s):
"""懒解析歌的音质档位数组;急切解析/无档位数据的歌返回 None。"""
if getattr(s, "download_url", None) != _LAZY_URL:
return None
fn = _QUALITY_TIER_FN.get(getattr(s, "source", None))
if fn is None:
return None
try:
return fn(s)
except Exception:
return None
def song_to_dict(s):
"""Serialize a musicdl SongInfo into a JSON-safe dict (missing fields -> null)."""
download_url = getattr(s, "download_url", None)
lazy = download_url == _LAZY_URL
# 原始搜索数据(解析链输入):懒解析条目 resolve 必需;已解析条目也携带,
# 供下载前重新解析(CDN 链接有时效,搜索时的 URL 放久了会过期)
raw_search = None
raw_data = getattr(s, "raw_data", None)
if isinstance(raw_data, dict):
rs = raw_data.get("search")
if isinstance(rs, dict):
raw_search = rs
# 懒解析歌的音质档位(格式+大小+无损);急切解析歌为 None(ext/fileSize 已确定)
qualities = _quality_tiers(s)
best = qualities[0] if qualities else None
# 懒解析歌原先 ext/大小/码率未知,先用最高档位回填,保证旧消费点(下载进度总量、
# 行内大小列、音质筛选)继续可用
ext = getattr(s, "ext", None)
file_size = getattr(s, "file_size", None)
file_size_bytes = getattr(s, "file_size_bytes", None)
bitrate = getattr(s, "bitrate", None)
if lazy and best:
if not ext:
ext = best.get("ext")
if not file_size and best.get("size"):
file_size = best.get("size")
if not file_size_bytes and best.get("sizeBytes"):
file_size_bytes = best.get("sizeBytes")
if not bitrate and best.get("bitrate"):
bitrate = best.get("bitrate")
return {
"songName": getattr(s, "song_name", None),
"singers": getattr(s, "singers", None),
"album": getattr(s, "album", None),
"duration": getattr(s, "duration", None),
"durationS": getattr(s, "duration_s", None),
"fileSize": file_size,
"fileSizeBytes": file_size_bytes,
"ext": ext,
"source": getattr(s, "source", None),
"rootSource": getattr(s, "root_source", None),
"downloadUrl": None if lazy else download_url,
"valid": bool(getattr(s, "with_valid_download_url", True)),
"coverUrl": getattr(s, "cover_url", None),
"bitrate": bitrate,
# 下载所需:identifiersave_path 命名用)+ 默认请求头/cookies(rust 引擎直传用)
"identifier": getattr(s, "identifier", None),
"defaultDownloadHeaders": getattr(s, "default_download_headers", None) or {},
"defaultDownloadCookies": getattr(s, "default_download_cookies", None) or {},
# resolve / 重新解析所需的原始搜索数据
"rawSearch": raw_search,
# 音质提示:懒解析歌 ext 未知时由 raw_search 推断(酷狗 SQ/ResFileSize、
# 网易 privilege.maxbr、QQ 批量详情补查);急切解析歌按 ext 判定。
# None = 无法推断(前端"仅无损"筛选不显示、"仅MP3"显示)
"lossless": _lossless_hint(s),
# 全音质档位(多档展示):懒解析歌为数组,急切解析/无数据为 null
"qualities": qualities,
}
# legalizestring(None) 会产出字面量 "NULL"(而非 None),脏解析结果的常见特征
_INVALID_META = {"", "null", "none", "n/a", "-"}
def _meta_garbage(d):
"""歌名/歌手为 "NULL" 之类占位值的条目视为解析失败的脏数据。"""
for field in ("songName", "singers"):
if str(d.get(field) or "").strip().lower() in _INVALID_META:
return True
return False
def _irrelevant(keyword, d):
"""关键词相关性兜底:结果歌名+歌手与关键词毫无重叠时视为解析跑偏
(第三方 API 偶发返回完全无关的歌曲)。中文按字、拉丁按词(≥2 字母)
宽松匹配,只要命中任意一个即视为相关。"""
text = "%s %s" % (d.get("songName") or "", d.get("singers") or "")
cjk = {ch for ch in keyword if "\u4e00" <= ch <= "\u9fff"}
if len(cjk) >= 2:
return not any(ch in text for ch in cjk)
words = {w.lower() for w in re.split(r"\W+", keyword) if len(w) >= 2 and w.isascii()}
if words:
low = text.lower()
return not any(w in low for w in words)
return False
def handle_search(params):
keyword = (params.get("keyword") or "").strip()
if not keyword:
raise ValueError("keyword 不能为空")
sources = params.get("sources") or DEFAULT_SOURCES
# 搜索全程按模块设置决定是否走代理(缺省强制直连,避免被系统代理带走)
with _use_proxy(params.get("proxy")):
client = get_client(sources, params.get("cookies"))
# musicdl 的 rich 进度条写 sys.stdout,必须重定向到 stderr 保持 JSON 通道干净
with _CLIENT_LOCK:
with contextlib.redirect_stdout(sys.stderr):
results = client.search(keyword)
# 懒解析歌批量补查音质档位:QQ 一次批量详情(其余源搜索项自带档位);不逐首解析
for source, songs in results.items():
if source == "QQMusicClient":
_fill_qq_lossless_hints(songs)
# 过滤脏条目:source 接口偶发返回 parsed 失败/字段缺失或跑偏的条目
# (空歌名、"NULL" 歌名/歌手、与关键词毫无关系的无关歌曲),直接剔除
out = {}
for source, songs in results.items():
kept = []
for s in songs:
d = song_to_dict(s)
if not d.get("songName") or _meta_garbage(d) or _irrelevant(keyword, d):
continue
kept.append(d)
if kept:
out[source] = kept
total = sum(len(v) for v in out.values())
return {"results": out, "total": total, "sources": list(out.keys())}
def handle_get_sources(params):
try:
from musicdl.modules import MusicClientBuilder
keys = sorted(MusicClientBuilder.REGISTERED_MODULES.keys())
except Exception:
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):
url = _normalize_netease_playlist_url((params.get("url") or "").strip())
if not url:
raise ValueError("url 不能为空")
sources = params.get("sources") or DEFAULT_SOURCES
# 与搜索一致:按模块设置决定是否走代理(缺省强制直连)
with _use_proxy(params.get("proxy")):
client = get_client(sources, params.get("cookies"))
# 网易云:懒解析——全部曲目都返回(含元数据),下载/试听时再逐首解析链接,
# 与搜索体验一致;musicdl 原生实现只保留解析出链接的歌,会让歌单"少歌"。
lazy_songs = _parse_netease_playlist_lazy(client, url) if _is_netease_url(url) else None
if lazy_songs is not None:
return {"songs": lazy_songs, "count": len(lazy_songs)}
# 其他源沿用 musicdl 的急切解析(QQ 的实现先查查询串取 id,无此问题)。
# musicdl 会按源依次尝试解析(第一个成功的源 break),rich 进度写 stdout 需重定向。
# 歌单解析须临时关闭懒解析:网易云歌单接口只回 trackIds(仅 id 无元数据),
# 懒元数据提取会得到空结果;此处保持急切解析(歌曲自带解析好的链接)
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
for s in songs
if (d := song_to_dict(s)) and d.get("songName") and not _meta_garbage(d)
]
return {"songs": out, "count": len(out)}
def handle_resolve(params):
"""懒解析:对搜索结果歌曲执行真实解析链(试听/rust 引擎批量下载前调用)。
支持单曲 {"song"} 或批量 {"songs"};返回与输入顺序对齐的列表,失败位为 null。"""
songs = params.get("songs") or []
if not songs and params.get("song"):
songs = [params["song"]]
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):
source = song.get("source") or ""
raw_search = song.get("rawSearch")
if not source or not isinstance(raw_search, dict):
return None
src_client = _get_resolve_client(source, resolve_cookies)
if src_client is None:
return None
# musicdl 的 rich 进度条写 sys.stdout,重定向到 stderr 保持 JSON 通道干净
with contextlib.redirect_stdout(sys.stderr):
resolved = _resolve_song(src_client, raw_search, target_quality)
if not resolved.with_valid_download_url:
return None
return song_to_dict(resolved)
with ThreadPoolExecutor(max_workers=min(4, len(songs))) as pool:
# 解析同样要联网:按模块设置决定是否走代理(缺省强制直连)。
# 工作线程读取的是同一个 _CURRENT_PROXY,块内一致。
with _use_proxy(params.get("proxy")):
out = list(pool.map(_resolve_one, songs))
return {"songs": out, "resolved": sum(1 for r in out if r)}
def handle_download(params):
task_id = params.get("taskId") or str(uuid.uuid4())
songs = params.get("songs") or []
savedir = params.get("savedir") or "."
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:
raise ValueError("songs 不能为空")
os.makedirs(savedir, exist_ok=True)
state = {"cancel": False}
_TASKS[task_id] = state
thread = threading.Thread(
target=_download_supervisor,
args=(task_id, songs, savedir, lyric, cover, proxy_url, cookies, state, max_concurrent, target_quality),
daemon=True,
)
thread.start()
return {"taskId": task_id, "queued": len(songs)}
def handle_cancel(params):
task_id = params.get("taskId") or ""
if task_id in _TASKS:
_TASKS[task_id]["cancel"] = True
return {"taskId": task_id, "cancelled": True}
return {"taskId": task_id, "cancelled": False}
def _download_supervisor(task_id, songs, savedir, lyric, cover, proxy_url, cookies, state, max_concurrent, target_quality=""):
"""下载监督线程:按 maxConcurrent 启动工作线程,逐首领取歌曲下载。
每个工作线程持有独立 MusicClient(互不共享、不与搜索客户端争锁),
因此搜索期间下载照常推进;取消为队列级(当前歌曲完成,其余标记取消)。"""
try:
from musicdl.modules.utils.data import SongInfo
sources = sorted({s.get("source") for s in songs if s.get("source")})
n_workers = max(1, min(max_concurrent, len(songs)))
next_idx = [0]
done = [0]
first_error = [None]
lock = threading.Lock() # 保护 next_idx / done / first_error
def worker():
try:
from musicdl.musicdl import MusicClient
# 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=worker_cfg,
)
except Exception as exc:
with lock:
if first_error[0] is None:
first_error[0] = "初始化下载客户端失败: %s" % exc
return
while True:
with lock:
if first_error[0] is not None:
return # 其他 worker 已失败,尽快退出
i = next_idx[0]
next_idx[0] += 1
if i >= len(songs):
return
song = songs[i]
if state["cancel"]:
_emit_event(
{"event": "download", "taskId": task_id, "type": "cancelled", "key": _song_key(song, i)}
)
continue
_download_one(client, SongInfo, task_id, i, song, savedir, lyric, cover, proxy_url, state, target_quality)
with lock:
done[0] += 1
threads = [threading.Thread(target=worker, daemon=True) for _ in range(n_workers)]
for t in threads:
t.start()
for t in threads:
t.join()
if first_error[0] is not None:
_emit_event({"event": "download", "taskId": task_id, "type": "error", "message": first_error[0]})
else:
_emit_event({"event": "download", "taskId": task_id, "type": "finished", "done": done[0], "total": len(songs)})
except Exception as exc:
_emit_event({"event": "download", "taskId": task_id, "type": "error", "message": str(exc)})
finally:
_TASKS.pop(task_id, None)
def _song_key(song, idx):
# source 缺失时用空串,与前端 `${s.source ?? ''}|${idx}` 的 key 保持一致
return f"{song.get('source') or ''}|{idx}"
# SongInfo.fromdict 按 snake_case 字段名匹配,而 song_to_dict 输出 camelCase
# 下载前需把键名映射回 snake_case,否则重建后字段全丢(song_name=None)。
_CAMEL_TO_SNAKE = {
"songName": "song_name",
"fileSize": "file_size",
"fileSizeBytes": "file_size_bytes",
"durationS": "duration_s",
"downloadUrl": "download_url",
"coverUrl": "cover_url",
"rootSource": "root_source",
"defaultDownloadHeaders": "default_download_headers",
"defaultDownloadCookies": "default_download_cookies",
}
def _to_songinfo_dict(song):
d = {_CAMEL_TO_SNAKE.get(k, k): v for k, v in song.items()}
# rawSearch(原始搜索数据)→ raw_data["search"],懒解析 resolve 的输入
raw_search = d.pop("rawSearch", None)
if raw_search is not None:
d["raw_data"] = {"search": raw_search}
return d
# Windows / 通用非法文件名字符,跨平台文件名清洗用
_INVALID_FILE_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
def _clean_filename(part):
"""清洗用于文件名的片段:剔除非法字符与首尾点/空格,为空时给占位。"""
return _INVALID_FILE_CHARS.sub("_", str(part or "")).strip().strip(" .") or "未知"
def _rename_downloaded_files(si, target):
"""下载落盘为「歌名 - <哈希标识>.ext」,改名为可读的「歌名 - 歌手.ext」(同步改名 .lrc)。
重命名失败不阻断下载完成上报。
"""
try:
singers = si.singers
if isinstance(singers, (list, tuple)):
singers = " / ".join(str(s) for s in singers if s)
base = _clean_filename(si.song_name)
if singers:
base = f"{base} - {_clean_filename(singers)}"
d = os.path.dirname(target) or "."
ext = (si.ext or "").lstrip(".") or os.path.splitext(target)[1].lstrip(".") or "mp3"
if not os.path.exists(target):
return
new_audio = os.path.join(d, f"{base}.{ext}")
if os.path.abspath(new_audio) != os.path.abspath(target):
i = 1
while os.path.exists(new_audio):
new_audio = os.path.join(d, f"{base} ({i}).{ext}")
i += 1
os.replace(target, new_audio)
old_lrc = os.path.splitext(target)[0] + ".lrc"
if os.path.exists(old_lrc):
os.replace(old_lrc, os.path.splitext(new_audio)[0] + ".lrc")
except Exception:
pass
def _resolved_quality_label(si):
"""解析结果的实际音质档位 label(与前端档位词表一致:Hi-Res/无损/320K/...)。
按解析出的 ext + 码率推断;码率单位自适应(bps > 10000 视为 bps 转 kbps)。"""
ext = str(getattr(si, "ext", "") or "").lower().lstrip(".")
br = _to_num(getattr(si, "bitrate", None))
kbps = br / 1000 if br > 10000 else br
if ext in _LOSSLESS_EXTS:
# flac 码率 ≥1500kbps 视为 Hi-Res96k/24bit ≈ 2200+44.1/16 ≈ 800~1000
return "Hi-Res" if kbps >= 1500 else "无损"
if kbps >= 320:
return "320K"
if kbps >= 192:
return "192K"
if kbps >= 128:
return "128K"
return ext.upper() if ext else ""
def _download_one(client, song_info_cls, task_id, idx, song, savedir, lyric, cover, proxy_url, state, target_quality=""):
key = _song_key(song, idx)
try:
si = song_info_cls.fromdict(_to_songinfo_dict(song))
# 懒解析:搜索阶段只取了元数据(或前端丢弃了可能过期的链接),下载前先解析
if si.download_url == _LAZY_URL or not si.with_valid_download_url:
search_result = (si.raw_data or {}).get("search")
src_client = client.music_clients.get(si.source) if isinstance(search_result, dict) else None
if src_client is None:
_emit_event({"event": "download", "taskId": task_id, "type": "error", "key": key, "message": "无有效下载链接"})
return
_emit_event({
"event": "download", "taskId": task_id, "type": "resolving", "key": key,
"songName": si.song_name, "singers": si.singers,
})
# worker 的 client 未打懒解析补丁,类方法即真实解析链;target_quality 封顶音质
with contextlib.redirect_stdout(sys.stderr):
si = _resolve_song(src_client, search_result, target_quality)
if not si.with_valid_download_url:
_emit_event({
"event": "download", "taskId": task_id, "type": "error", "key": key,
"message": "解析下载链接失败(可能为付费歌曲或源暂时不可用)",
})
return
elif si.download_url:
# 搜索时已解析:fromdict 重建后 download_url_status 为空,
# 导致 with_valid_download_url=False 被 musicdl 的下载过滤器剔除,需强制置为可下载
si.download_url_status = {"ok": True}
si.work_dir = savedir
# identifier 缺失时用 URL 哈希兜底,否则 save_path 会生成 "歌名 - None.ext"
if si.identifier is None:
si.identifier = hashlib.md5(str(si.download_url or si.song_name).encode("utf-8")).hexdigest()[:8]
# 预计算目标路径(save_path 属性会建目录并缓存 _save_path)
target = si.save_path
total = si.file_size_bytes or 0
_emit_event({
"event": "download", "taskId": task_id, "type": "start", "key": key,
"songName": si.song_name, "singers": si.singers, "ext": si.ext, "total": total,
"quality": _resolved_quality_label(si),
})
# 进度轮询线程:每 300ms 上报目标文件大小(下载完成时停)
stop_poll = threading.Event()
poller = threading.Thread(
target=_poll_progress, args=(task_id, key, target, total, stop_poll), daemon=True
)
poller.start()
try:
source = si.source
src_client = client.music_clients.get(source)
if src_client is None:
raise ValueError(f"源 {source} 不在当前客户端中")
overrides = {}
if proxy_url:
overrides["proxies"] = {"http": proxy_url, "https": proxy_url}
# auto_supplement_song=False 时跳过歌词/封面/标签写入
supplement = lyric or cover
# client 为本 worker 独立实例,无需 _CLIENT_LOCK(不与搜索/其他 worker 争锁);
# 并发的 redirect_stdout 均重定向到 stderr,协议写入走 _PROTOCOL_STDOUT 不受影响
with contextlib.redirect_stdout(sys.stderr):
results = src_client.download(
song_infos=[si],
num_threadings=1,
request_overrides=overrides,
auto_supplement_song=supplement,
)
ok = len(results) > 0
finally:
stop_poll.set()
# musicdl 每次 download 结束都会往保存目录写 download_results.pkl(对桥接无用),清理掉
try:
_pkl = os.path.join(savedir, "download_results.pkl")
if os.path.exists(_pkl):
os.remove(_pkl)
except Exception:
pass
if ok:
_rename_downloaded_files(si, target)
_emit_event({"event": "download", "taskId": task_id, "type": "done", "key": key})
else:
_emit_event({"event": "download", "taskId": task_id, "type": "error", "key": key, "message": "下载未产生有效结果"})
except Exception as exc:
_emit_event({"event": "download", "taskId": task_id, "type": "error", "key": key, "message": str(exc)})
def _poll_progress(task_id, key, target, total, stop_poll):
"""轮询目标文件大小并上报进度,直到 stop_poll 置位。"""
last = 0
while not stop_poll.is_set():
try:
size = os.path.getsize(target) if os.path.exists(target) else 0
except Exception:
size = 0
if size != last:
last = size
_emit_event({
"event": "download", "taskId": task_id, "type": "progress",
"key": key, "downloaded": size, "total": total,
})
time.sleep(0.3)
def handle(method, params):
"""Dispatch a request. Raise ValueError for unknown methods."""
if method == "ping":
return {
"version": "0.1.0",
"python": sys.version.split()[0],
}
if method == "get_sources":
return handle_get_sources(params)
if method == "parse_playlist":
return handle_parse_playlist(params)
if method == "resolve":
return handle_resolve(params)
if method == "search":
return handle_search(params)
if method == "download":
return handle_download(params)
if method == "cancel":
return handle_cancel(params)
raise ValueError("unknown method: %s" % method)
def main():
global _PROTOCOL_STDOUT
# 编码对齐:Rust 侧以 UTF-8 字节写入 stdinserde_json 序列化不转义非 ASCII),
# 而 Windows 下 Python 管道默认按 locale(中文系统为 GBK)解码 stdin,
# 中文关键词会变成乱码导致搜索结果错乱,必须显式切换为 UTF-8
for stream in (sys.stdin, sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8")
except Exception:
pass
_PROTOCOL_STDOUT = sys.stdout
for line in sys.stdin:
line = line.strip()
if not line:
continue
req_id = None
try:
req = json.loads(line)
req_id = req.get("id")
method = req.get("method", "")
params = req.get("params") or {}
result = handle(method, params)
with _WRITE_LOCK:
_out().write(
json.dumps({"id": req_id, "ok": True, "result": result}, ensure_ascii=True)
+ "\n"
)
_out().flush()
except SystemExit:
raise
except Exception as exc:
with _WRITE_LOCK:
_out().write(
json.dumps({"id": req_id, "ok": False, "error": str(exc)}, ensure_ascii=True)
+ "\n"
)
_out().flush()
if __name__ == "__main__":
main()