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