feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,390 @@
|
||||
"""咪咕音乐 取流(对应 sdk/apis/mg.ts)。
|
||||
|
||||
候选链:listen-url 多档位请求 → 按档位优先级产出「内嵌加密 / 明文直链 / 档位路径重写」
|
||||
三类候选 → 逐条 Range 0-63 探测(校验音频魔数,加密流先解密再校验)→ 返回实际命中的最高档位。
|
||||
|
||||
匿名态仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie(接口回「登录后免费试听」)。
|
||||
|
||||
加密流:返回 `{"encrypted": True, "fileKey": "..."}`,路由据此改写成 `/stream/mg/<票据>`,
|
||||
真正的逐字节解密在 `app/http/mg_stream.py`(本模块只负责产出字段与探测阶段的自校验)。
|
||||
|
||||
上游对应 music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
|
||||
selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.mg.util import (
|
||||
ANDROID_CHANNEL,
|
||||
ANDROID_UA,
|
||||
ANDROID_VERSION,
|
||||
TONE_EXT,
|
||||
TONE_QUALITY,
|
||||
decrypt_z3d,
|
||||
first_non_empty,
|
||||
get_migu_cookie,
|
||||
is_migu_audio_magic,
|
||||
migu_format_ext,
|
||||
normalize_tone,
|
||||
parse_int,
|
||||
)
|
||||
|
||||
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
|
||||
|
||||
LISTEN_URL = "https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url"
|
||||
|
||||
#: 平台档位 → 咪咕 toneFlag(取流请求用)
|
||||
QUALITY_TONE: dict[str, str] = {
|
||||
"64k": "LQ",
|
||||
"128k": "PQ",
|
||||
"320k": "HQ",
|
||||
"flac": "SQ",
|
||||
"flac24bit": "ZQ24",
|
||||
"wav": "ZQ32",
|
||||
}
|
||||
|
||||
#: 可重写的 tone 路径(上游 miguTonePaths)
|
||||
TONE_PATHS: dict[str, str] = {
|
||||
"LQ": "全曲试听/Mp3_64_22_16",
|
||||
"PQ": "标清高清/MP3_128_16_Stero",
|
||||
"HQ": "标清高清/MP3_320_16_Stero",
|
||||
"SQ": "歌曲下载/flac",
|
||||
"ZQ": "歌曲下载/flac_24bit",
|
||||
"ZQ24": "歌曲下载/flac_24bit",
|
||||
"ZQ32": "歌曲下载/wav_32bit",
|
||||
}
|
||||
|
||||
TONE_PREFERENCE: list[str] = ["ZQ32", "ZQ24", "ZQ", "SQ", "HQ", "Z3D", "I3D", "3D60", "PQ", "LQ"]
|
||||
|
||||
#: 档位路径探测顺序(长的在前,避免 `/flac` 抢匹配 `/flac_24bit`)
|
||||
_TONE_PATH_ORDER = ("ZQ24", "ZQ", "ZQ32", "SQ", "HQ", "LQ", "PQ")
|
||||
|
||||
MAX_PROBES = 10
|
||||
|
||||
|
||||
def migu_song_parts(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
"""songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c`。"""
|
||||
extra = song_info.get("extra") or {}
|
||||
extra = extra if isinstance(extra, dict) else {}
|
||||
content_id = str(extra.get("content_id") or "").strip()
|
||||
resource_type = str(extra.get("resource_type") or "").strip()
|
||||
format_type = str(extra.get("format_type") or "").strip()
|
||||
if content_id and resource_type and format_type:
|
||||
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
|
||||
raw_id = str(song_info.get("songmid") or song_info.get("id") or "")
|
||||
parts = raw_id.split("|")
|
||||
if len(parts) == 3:
|
||||
return {"contentId": parts[0].strip(), "resourceType": parts[1].strip(), "formatType": parts[2].strip()}
|
||||
if not content_id:
|
||||
content_id = raw_id.strip()
|
||||
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
|
||||
|
||||
|
||||
async def _fetch_listen_info(
|
||||
content_id: str, copyright_id: str, song_id: str, album_id: str, resource_type: str, tone_flag: str
|
||||
) -> dict[str, Any]:
|
||||
"""listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误。"""
|
||||
params: list[tuple[str, str]] = [
|
||||
("netType", "01"),
|
||||
("resourceType", first_non_empty(resource_type, "2")),
|
||||
("contentId", content_id.strip()),
|
||||
("toneFlag", first_non_empty(tone_flag, "PQ")),
|
||||
]
|
||||
if copyright_id.strip():
|
||||
params.append(("copyrightId", copyright_id.strip()))
|
||||
if song_id.strip():
|
||||
params.append(("songId", song_id.strip()))
|
||||
if album_id.strip():
|
||||
params.append(("albumId", album_id.strip()))
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": ANDROID_UA,
|
||||
"channel": ANDROID_CHANNEL,
|
||||
"version": ANDROID_VERSION,
|
||||
"Referer": "https://music.migu.cn/",
|
||||
}
|
||||
cookie = get_migu_cookie()
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
|
||||
resp = await http_fetch(f"{LISTEN_URL}?{urlencode(params)}", headers=headers, timeout=30_000)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
raise RuntimeError(f"migu listen endpoint returned status {resp.status_code}")
|
||||
result = resp.body if isinstance(resp.body, dict) else {}
|
||||
if result.get("code") and result["code"] != "000000":
|
||||
raise RuntimeError(f"migu api error: {result.get('info')} (code {result.get('code')})")
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_download_url(raw_url: Any) -> str:
|
||||
url = str(raw_url if raw_url is not None else "").strip()
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("//"):
|
||||
url = f"https:{url}"
|
||||
prefix = "ftp://218.200.160.122:21/"
|
||||
if url.startswith(prefix):
|
||||
url = f"https://freetyst.nf.migu.cn/{url[len(prefix) :]}"
|
||||
return url
|
||||
|
||||
|
||||
def _direct_download_info(raw_url: str, fmt: str, expected_size: int) -> dict[str, Any] | None:
|
||||
url = _normalize_download_url(raw_url)
|
||||
if not url:
|
||||
return None
|
||||
ext = ""
|
||||
try:
|
||||
path = urlsplit(url).path
|
||||
ext = (path.split(".")[-1] or "").lower()
|
||||
if not ext or "/" in ext:
|
||||
ext = ""
|
||||
except ValueError:
|
||||
ext = ""
|
||||
tone = normalize_tone(fmt)
|
||||
if not ext:
|
||||
ext = TONE_EXT.get(tone, "")
|
||||
if not ext:
|
||||
ext = "mp3"
|
||||
if not tone:
|
||||
# 按扩展名回填档位(上游 formatFromMiguExt)
|
||||
tone = {"flac": "SQ", "wav": "ZQ32", "m4a": "I3D"}.get(ext, "PQ")
|
||||
return {"url": url, "format": tone, "ext": ext, "encrypted": False, "fileKey": "", "size": expected_size}
|
||||
|
||||
|
||||
def _embedded_download_info(resp: dict[str, Any], target_tone: str, expected_size: int) -> dict[str, Any] | None:
|
||||
"""z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse。"""
|
||||
data = resp.get("data") if isinstance(resp, dict) else None
|
||||
song_item = (data or {}).get("songItem") if isinstance(data, dict) else None
|
||||
code = (song_item or {}).get("z3dCode") if isinstance(song_item, dict) else None
|
||||
if not isinstance(code, dict):
|
||||
return None
|
||||
|
||||
def parse_size(value: Any) -> int:
|
||||
size = parse_int(value)
|
||||
return size if size > 0 else 0
|
||||
|
||||
if target_tone == "Z3D":
|
||||
url = _normalize_download_url(first_non_empty(code.get("androidUrl"), code.get("url")))
|
||||
file_key = str(code.get("androidFileKey") or "").strip()
|
||||
if not url or not file_key:
|
||||
return None
|
||||
ext = first_non_empty(str(code.get("androidFileType") or "").removeprefix("."), "wav")
|
||||
return {
|
||||
"url": url,
|
||||
"format": "Z3D",
|
||||
"ext": ext,
|
||||
"encrypted": True,
|
||||
"fileKey": file_key,
|
||||
"size": parse_size(code.get("androidSize")) or expected_size,
|
||||
}
|
||||
if target_tone == "I3D":
|
||||
url = _normalize_download_url(first_non_empty(code.get("iosUrl"), code.get("url")))
|
||||
file_key = str(code.get("iosFileKey") or "").strip()
|
||||
if not url or not file_key:
|
||||
return None
|
||||
ext = first_non_empty(str(code.get("iosFileType") or "").removeprefix("."), "m4a")
|
||||
return {
|
||||
"url": url,
|
||||
"format": "I3D",
|
||||
"ext": ext,
|
||||
"encrypted": True,
|
||||
"fileKey": file_key,
|
||||
"size": parse_size(code.get("iosSize")) or expected_size,
|
||||
}
|
||||
if target_tone == "3D60":
|
||||
url = _normalize_download_url(first_non_empty(code.get("url"), code.get("h5Url")))
|
||||
if not url:
|
||||
return None
|
||||
ext = first_non_empty(str(code.get("fileType") or "").removeprefix("."), "wav")
|
||||
return {
|
||||
"url": url,
|
||||
"format": "3D60",
|
||||
"ext": ext,
|
||||
"encrypted": False,
|
||||
"fileKey": "",
|
||||
"size": parse_size(code.get("size")) or expected_size,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def _detect_tone_path(decoded_path: str) -> str:
|
||||
for tone in _TONE_PATH_ORDER:
|
||||
tone_path = TONE_PATHS.get(tone, "")
|
||||
if tone_path and tone_path in decoded_path:
|
||||
return tone_path
|
||||
return ""
|
||||
|
||||
|
||||
def _rewrite_tone_url(raw_url: str, target_tone: str) -> str | None:
|
||||
"""把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL)。"""
|
||||
tone = normalize_tone(target_tone)
|
||||
target_path = TONE_PATHS.get(tone)
|
||||
target_ext = TONE_EXT.get(tone)
|
||||
if not target_path or not target_ext or tone in ("Z3D", "I3D", "3D60"):
|
||||
return None
|
||||
try:
|
||||
parts = urlsplit(_normalize_download_url(raw_url))
|
||||
except ValueError:
|
||||
return None
|
||||
decoded_path = unquote(parts.path)
|
||||
source_path = _detect_tone_path(decoded_path)
|
||||
if not source_path:
|
||||
return None
|
||||
new_path = decoded_path.replace(source_path, target_path)
|
||||
if new_path == decoded_path:
|
||||
return None
|
||||
dot_index = new_path.rfind(".")
|
||||
if dot_index > new_path.rfind("/"):
|
||||
new_path = new_path[:dot_index]
|
||||
new_path += f".{target_ext}"
|
||||
# JS 的 `parsed.pathname = ...` 会把中文等非 ASCII 重新百分号编码
|
||||
return urlunsplit((parts.scheme, parts.netloc, quote(new_path, safe="/"), parts.query, parts.fragment))
|
||||
|
||||
|
||||
def _build_candidates(responses: list[dict[str, Any]], expected_size: int) -> list[dict[str, Any]]:
|
||||
"""候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选。"""
|
||||
sources: list[dict[str, str]] = []
|
||||
seen_sources: set[str] = set()
|
||||
for resp in responses:
|
||||
data = resp.get("data") if isinstance(resp, dict) else None
|
||||
candidate_url = _normalize_download_url((data or {}).get("url") if isinstance(data, dict) else None)
|
||||
if not candidate_url:
|
||||
continue
|
||||
actual_format = normalize_tone((data or {}).get("formatType")) or "PQ"
|
||||
key = f"{actual_format}\u0000{candidate_url}"
|
||||
if key in seen_sources:
|
||||
continue
|
||||
seen_sources.add(key)
|
||||
sources.append({"url": candidate_url, "format": actual_format})
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(info: dict[str, Any] | None) -> None:
|
||||
if not info or not info["url"]:
|
||||
return
|
||||
key = f"{info['format']}\u0000{info['url']}"
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
candidates.append(info)
|
||||
|
||||
for tone in TONE_PREFERENCE:
|
||||
for resp in responses:
|
||||
add(_embedded_download_info(resp, tone, expected_size))
|
||||
for source in sources:
|
||||
if tone == source["format"]:
|
||||
add(_direct_download_info(source["url"], source["format"], expected_size))
|
||||
continue
|
||||
rewritten = _rewrite_tone_url(source["url"], tone)
|
||||
if rewritten:
|
||||
add(_direct_download_info(rewritten, tone, 0))
|
||||
return candidates
|
||||
|
||||
|
||||
async def _download_info_valid(info: dict[str, Any]) -> bool:
|
||||
"""探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积。"""
|
||||
try:
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": ANDROID_UA,
|
||||
"Referer": "https://music.migu.cn/",
|
||||
"Range": "bytes=0-63",
|
||||
}
|
||||
cookie = get_migu_cookie()
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
resp = await http_fetch(info["url"], headers=headers, timeout=10_000)
|
||||
if resp.status_code < 200 or resp.status_code >= 300:
|
||||
return False
|
||||
prefix = resp.raw[:64]
|
||||
if not prefix:
|
||||
return False
|
||||
if info["encrypted"]:
|
||||
try:
|
||||
decrypt_z3d(prefix, info["fileKey"])
|
||||
except Exception: # noqa: BLE001 - 探测失败即视为该候选不可用
|
||||
return False
|
||||
elif not is_migu_audio_magic(prefix):
|
||||
return False
|
||||
content_range = resp.header("content-range")
|
||||
slash = content_range.rfind("/")
|
||||
if slash >= 0:
|
||||
size = parse_int(content_range[slash + 1 :])
|
||||
if size > 0:
|
||||
info["size"] = size
|
||||
return True
|
||||
except Exception: # noqa: BLE001 - 任一候选探测异常都只代表该候选不可用
|
||||
return False
|
||||
|
||||
|
||||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""取流入口:候选链 + 逐档探测;返回实际命中的最高档位。"""
|
||||
parts = migu_song_parts(song_info)
|
||||
content_id = parts["contentId"]
|
||||
if not content_id:
|
||||
raise RuntimeError("migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)")
|
||||
resource_type = parts["resourceType"] or "2"
|
||||
target_tone = (
|
||||
normalize_tone(QUALITY_TONE.get(quality, ""))
|
||||
or normalize_tone(parts["formatType"])
|
||||
or normalize_tone(song_info.get("ext"))
|
||||
or "PQ"
|
||||
)
|
||||
|
||||
extra = song_info.get("extra") or {}
|
||||
extra = extra if isinstance(extra, dict) else {}
|
||||
copyright_id = str(extra.get("copyright_id") or "")
|
||||
song_id = str(extra.get("song_id") or "")
|
||||
album_id = str(extra.get("album_id") or song_info.get("albumId") or "")
|
||||
|
||||
request_tones: list[str] = []
|
||||
for tone in ("ZQ32", target_tone, "PQ"):
|
||||
if tone not in request_tones:
|
||||
request_tones.append(tone)
|
||||
|
||||
responses: list[dict[str, Any]] = []
|
||||
fetch_err: Exception | None = None
|
||||
for tone in request_tones:
|
||||
try:
|
||||
responses.append(await _fetch_listen_info(content_id, copyright_id, song_id, album_id, resource_type, tone))
|
||||
except Exception as err: # noqa: BLE001 - 多档位请求,单档失败不影响其它档
|
||||
if fetch_err is None:
|
||||
fetch_err = err
|
||||
if not responses:
|
||||
raise fetch_err or RuntimeError("migu listen-url 全部失败")
|
||||
|
||||
expected_size = parse_int(song_info.get("size"))
|
||||
candidates = _build_candidates(responses, expected_size)
|
||||
for probed, candidate in enumerate(candidates):
|
||||
if probed >= MAX_PROBES:
|
||||
break
|
||||
if await _download_info_valid(candidate):
|
||||
result: dict[str, Any] = {
|
||||
"type": TONE_QUALITY.get(candidate["format"], quality),
|
||||
"url": candidate["url"],
|
||||
}
|
||||
if candidate["encrypted"]:
|
||||
result["encrypted"] = True
|
||||
result["fileKey"] = candidate["fileKey"]
|
||||
result["ext"] = candidate["ext"] or migu_format_ext(candidate["format"])
|
||||
result["size"] = candidate["size"]
|
||||
return result
|
||||
|
||||
# 全部候选不可用:优先回传接口文案(如「登录后免费试听」)
|
||||
dialog = next(
|
||||
(
|
||||
str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
|
||||
for resp in responses
|
||||
if str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
|
||||
),
|
||||
"",
|
||||
)
|
||||
if dialog:
|
||||
raise RuntimeError(f"migu 无可用音频:{dialog}(登录后可解锁更高档位)")
|
||||
if fetch_err is not None:
|
||||
raise fetch_err
|
||||
raise RuntimeError("migu 未返回可访问的音频档位")
|
||||
Reference in new issue
Block a user