1617 lines
69 KiB
Python
1617 lines
69 KiB
Python
#!/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")
|
||
|
||
# 懒解析占位 URL:musicdl 的 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_url(with_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
|
||
|
||
# QQ:SongFileType.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_QUALITIES(lossless=flac, exhigh=320K, standard=128K)
|
||
_NETEASE_CAPS = {
|
||
"Hi-Res": None,
|
||
"无损": {"lossless", "dolby", "exhigh", "standard"},
|
||
"320K": {"exhigh", "standard"},
|
||
"192K": {"standard"},
|
||
"128K": {"standard"},
|
||
"96K": {"standard"},
|
||
}
|
||
# 酷狗:MUSIC_QUALITIES(flac=无损, 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 的假 list(QQ 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"])
|
||
# 酷狗:SQ(flac)/ Res(hires)文件大小
|
||
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_lossless(bool);失败静默(保持未知)。"""
|
||
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,
|
||
}
|
||
|
||
|
||
# QQ:musicu.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-Res(flac)
|
||
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/128K(mp3/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,
|
||
# 下载所需:identifier(save_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 音乐 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=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-Res(96k/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 字节写入 stdin(serde_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()
|