Files
MusicServer/app/sdk/platforms/mg/util.py
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

461 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""咪咕音乐公共层(对应 sdk/mg/util.ts)。
常量、请求封装、条目 → 歌曲的统一转换、档位/扩展名工具与 Z3D 解密。
上游对应 music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等)。
匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie
(接口返回「登录后免费试听」)。
Z3D 解密与 `app/http/mg_stream.py` 的流式实现同源:那边服务 /stream/mg 代理,
这里供取流探测阶段校验候选是否真能解出音频,两处算法必须逐字一致。
"""
from __future__ import annotations
import hashlib
import math
import re
from typing import Any
from urllib.parse import urlencode
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.auth_store import get_cookie
from app.sdk.common.request import http_fetch
# ---------------- 常量 ----------------
#: 咪咕 toneFlag → 平台音质标签(types 展示与取流返回共用)
TONE_QUALITY: dict[str, str] = {
"LQ": "64k",
"PQ": "128k",
"HQ": "320k",
"SQ": "flac",
"ZQ": "flac24bit",
"ZQ24": "flac24bit",
"ZQ32": "wav",
"Z3D": "wav",
"I3D": "flac24bit",
"3D60": "wav",
}
#: tone → 明文容器扩展名(取流侧判定档位与扩展名共用)
TONE_EXT: dict[str, str] = {
"LQ": "mp3",
"PQ": "mp3",
"HQ": "mp3",
"SQ": "flac",
"ZQ": "flac",
"ZQ24": "flac",
"ZQ32": "wav",
"Z3D": "wav",
"I3D": "m4a",
"3D60": "wav",
}
USER_AGENT = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
)
REFERER = "http://music.migu.cn/"
#: listen-url 走 Android 客户端 UA/头,与网页通道不同 UA
ANDROID_UA = "Android_migu/7.41.13 okhttp/3.12.13"
ANDROID_CHANNEL = "0146832"
ANDROID_VERSION = "7.41.13"
SONG_DETAIL_URL = "http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do"
# Z3D 加密流的 app code 密文(AES-128-CBC,key 为 AppKey 前 16 字符)
_ANDROID_APP_KEY = b"308202333082019c"
_ENCRYPTED_APP_CODE = bytes(
[
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
]
) # fmt: skip
_Z3D_IV = bytes(range(16))
_INT_PREFIX = re.compile(r"[+-]?\d+")
# ---------------- 请求 ----------------
def get_migu_cookie() -> str:
return get_cookie("mg").strip()
async def migu_get(url: str, extra_headers: dict[str, str] | None = None) -> Any:
"""带 Referer/UA/Cookie 的 GET,返回解析后的 body(JSON 已解析)。"""
headers: dict[str, str] = {"User-Agent": USER_AGENT, "Referer": REFERER, **(extra_headers or {})}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(url, headers=headers, timeout=20_000)
if resp.status_code != 200:
raise RuntimeError(f"migu http {resp.status_code}")
return resp.body
def build_url(base: str, params: dict[str, Any]) -> str:
"""URLSearchParams 语义:值统一 String 后 form-urlencoded。"""
return f"{base}?{urlencode([(key, str(value)) for key, value in params.items()])}"
# ---------------- 工具函数(1:1 对应上游) ----------------
def parse_int(value: Any) -> int:
"""JS parseInt 语义:取前导整数,解析不出返回 0。"""
match = _INT_PREFIX.match(str(value if value is not None else "").strip())
return int(match.group()) if match else 0
def to_number(value: Any) -> float:
"""JS Number() 语义:不可解析或非有限值归零(分页 totalCount / 体积等字段用)。"""
try:
number = float(str(value if value is not None else "").strip())
except (TypeError, ValueError):
return 0.0
return number if math.isfinite(number) else 0.0
def first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
if str(value).strip():
return str(value)
return ""
def first_non_zero_string(*values: Any) -> str:
for value in values:
text = str(value if value is not None else "").strip()
if text and text != "0":
return text
return ""
def pick_migu_image(items: Any) -> str:
"""按 02/01/03 的尺寸优先级取图,都没有则取第一张非空。"""
if not isinstance(items, list):
return ""
for preferred in ("02", "01", "03"):
for item in items:
entry = item if isinstance(item, dict) else {}
if entry.get("imgSizeType") == preferred and str(entry.get("img") or "").strip():
return str(entry.get("img"))
for item in items:
entry = item if isinstance(item, dict) else {}
if str(entry.get("img") or "").strip():
return str(entry.get("img"))
return ""
def normalize_migu_image_url(image: Any) -> str:
url = str(image if image is not None else "").strip()
if not url:
return ""
if url.startswith(("http://", "https://")):
return url
if url.startswith("//"):
return f"https:{url}"
if url.startswith("/"):
return f"https://d.musicapp.migu.cn{url}"
return url
def collect_migu_artist_names(item: dict[str, Any]) -> list[str]:
names: list[str] = []
seen: set[str] = set()
def append(name: Any) -> None:
text = str(name if name is not None else "").strip()
if not text or text in seen:
return
seen.add(text)
names.append(text)
for group in ("singers", "singerList", "artists"):
for singer in item.get(group) or []:
append((singer or {}).get("name") if isinstance(singer, dict) else None)
if not names:
for name in str(item.get("singer") or "").split("|"):
append(name)
return names
_Z3D_CODE_KEYS = (
"formatType",
"resourceType",
"size",
"androidSize",
"isize",
"asize",
"fileType",
"androidFileType",
"iformat",
"aformat",
"price",
)
def collect_migu_formats(item: dict[str, Any]) -> list[dict[str, Any]]:
"""汇总三组 rateFormats + z3dCode,按 formatType|resourceType 去重。"""
groups: list[list[dict[str, Any]]] = []
for key in ("newRateFormats", "rateFormats", "audioFormats"):
group = item.get(key)
if isinstance(group, list):
groups.append([entry for entry in group if isinstance(entry, dict)])
code = item.get("z3dCode")
if isinstance(code, dict) and code.get("formatType"):
groups.append([{key: code.get(key) for key in _Z3D_CODE_KEYS}])
formats: list[dict[str, Any]] = []
seen: set[str] = set()
for group in groups:
for fmt in group:
key = f"{str(fmt.get('formatType') or '').strip().upper()}|{str(fmt.get('resourceType') or '').strip()}"
if key == "|" or key in seen:
continue
seen.add(key)
formats.append(fmt)
return formats
_FORMAT_RANK: dict[str, int] = {
"ZQ32": 80,
"ZQ": 75,
"ZQ24": 75,
"SQ": 70,
"HQ": 60,
"I3D": 50,
"Z3D": 50,
"3D60": 40,
"PQ": 30,
"LQ": 20,
}
def migu_format_rank(format_type: Any = "") -> int:
return _FORMAT_RANK.get(str(format_type or "").strip().upper(), 10)
def migu_format_ext(format_type: Any = "", format_code: Any = "") -> str:
tone = str(format_type or "").strip().upper()
code = str(format_code or "").strip()
if tone in ("SQ", "ZQ", "ZQ24"):
return "flac"
if tone in ("ZQ32", "Z3D", "3D60"):
return "wav"
if tone == "I3D":
return "m4a"
if tone in ("PQ", "HQ", "LQ"):
return "mp3"
return "flac" if code.startswith("011") else "mp3"
def normalize_tone(value: Any = "") -> str:
"""取流档位归一:去掉前导点、大写,只认 TONE_EXT 里已知的 toneFlag。"""
text = str(value if value is not None else "").strip()
if text.startswith("."):
text = text[1:]
text = text.upper()
return text if text in TONE_EXT else ""
def migu_album_link(album_id: str) -> str:
return f"https://music.migu.cn/v3/music/album/{album_id}"
def migu_playlist_link(playlist_id: str) -> str:
return f"https://music.migu.cn/v5/#/playlist?playlistId={playlist_id}&playlistType=ordinary"
def migu_song_link(content_id: str) -> str:
return f"https://music.migu.cn/v3/music/song/{content_id}"
def format_seconds(seconds: float) -> str:
minutes = math.floor(seconds / 60)
secs = math.floor(seconds % 60)
return f"{minutes:02d}:{secs:02d}"
# ---------------- 条目 → 歌曲(上游 convertItemToSongWithOption) ----------------
def _seconds_from_size(size: int, bitrate: int) -> int:
return math.floor(size * 8 / bitrate) if bitrate > 0 else 0
def convert_item_to_song(item: dict[str, Any], allow_paid: bool = True) -> dict[str, Any] | None:
artist_names = collect_migu_artist_names(item)
song_name = first_non_empty(item.get("name"), item.get("songName"))
album_name = str(item.get("album") or "").strip()
albums = item.get("albums") or []
if isinstance(albums, list) and albums:
first_album = albums[0] if isinstance(albums[0], dict) else {}
if str(first_album.get("name") or "").strip():
album_name = str(first_album.get("name")).strip()
formats = collect_migu_formats(item)
if not formats:
return None
duration = to_number(item.get("duration"))
download_tags = [str(tag or "").strip().lower() for tag in (item.get("downloadTags") or [])]
candidates: list[dict[str, Any]] = []
for index, fmt in enumerate(formats):
size_text = first_non_zero_string(fmt.get("androidSize"), fmt.get("asize"), fmt.get("size"), fmt.get("isize"))
size_value = parse_int(size_text)
ext = first_non_empty(fmt.get("androidFileType"), fmt.get("fileType"))
if not ext:
ext = migu_format_ext(fmt.get("formatType"), first_non_empty(fmt.get("aformat"), fmt.get("iformat")))
if not duration and size_value > 0:
bitrate = {"PQ": 128_000, "HQ": 320_000, "LQ": 64_000}.get(str(fmt.get("formatType") or ""), 0)
if bitrate > 0:
duration = _seconds_from_size(size_value, bitrate)
price_value = parse_int(fmt.get("price"))
show_tags = fmt.get("showTag")
tags = show_tags if isinstance(show_tags, list) and show_tags else (fmt.get("showTags") or [])
is_vip_tag = any(str(tag or "").strip().lower() == "vip" for tag in tags)
if not is_vip_tag:
is_vip_tag = "vip" in download_tags
hidden_paid = item.get("chargeAuditions") == "1" and price_value >= 200
if allow_paid or (not is_vip_tag and not hidden_paid):
candidates.append(
{
"index": index,
"size": size_value,
"ext": ext,
"rank": migu_format_rank(fmt.get("formatType")),
"vip": is_vip_tag or hidden_paid,
"tone": str(fmt.get("formatType") or ""),
}
)
if not candidates:
return None
candidates.sort(key=lambda entry: (-entry["rank"], -entry["size"]))
best = candidates[0]
best_format = formats[best["index"]]
cover_url = normalize_migu_image_url(
first_non_empty(
pick_migu_image(item.get("imgItems")),
pick_migu_image(item.get("albumImgs")),
item.get("img1"),
item.get("img2"),
item.get("img3"),
)
)
# albums 数组是搜索响应的专辑来源(顶层 albumId 常为空),与上游 Extra 的 album_id 同义
album_id = first_non_empty(item.get("albumId"), (albums[0] if albums else {}).get("id"))
link_id = first_non_empty(item.get("contentId"), item.get("copyrightId"))
resource_type = best_format.get("resourceType")
format_type = best_format.get("formatType")
extra: dict[str, str] = {
"content_id": str(item.get("contentId") or ""),
# 注意:extra 用空串兜底,songmid 用 '2' 兜底(与原文两处写法不同)
"resource_type": str(resource_type if resource_type is not None else ""),
"format_type": str(format_type if format_type is not None else ""),
}
if item.get("copyrightId"):
extra["copyright_id"] = str(item["copyrightId"])
if item.get("songId"):
extra["song_id"] = str(item["songId"])
if album_id:
extra["album_id"] = album_id
is_vip = item.get("chargeAuditions") == "1" or bool(best["vip"])
songmid = f"{item.get('contentId') or ''}|{resource_type if resource_type is not None else '2'}|{format_type or ''}"
# 各档位可用性(供前端展示);实际可取档位由取流接口的探测结果决定
types = [{"type": TONE_QUALITY.get(entry["tone"], "128k"), "size": str(entry["size"])} for entry in candidates]
return {
"songmid": songmid,
"name": song_name,
"singer": " / ".join(artist_names),
"albumId": album_id,
"albumName": album_name,
"interval": format_seconds(duration) if duration else "0",
"img": cover_url or None,
"lrc": None,
"types": types,
"_types": {},
"payPlay": is_vip,
# 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里
"extra": {**extra, "link": migu_song_link(link_id)},
"source": "mg",
}
# ---------------- 歌曲详情 ----------------
async def fetch_song_detail(content_id: str) -> dict[str, Any]:
url = build_url(SONG_DETAIL_URL, {"resourceType": "2", "resourceId": content_id})
body = await migu_get(url)
resource = body.get("resource") if isinstance(body, dict) else None
if not resource:
raise RuntimeError("migu 歌曲详情为空")
return resource[0]
# ---------------- Z3D 解密(上游 DecryptAudio / miguZ3DStreamKey) ----------------
def is_migu_audio_magic(data: bytes) -> bool:
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
if not data or len(data) < 4:
return False
if data[:4] in (b"fLaC", b"OggS"):
return True
if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WAVE":
return True
if data[:3] == b"ID3":
return True
if len(data) >= 12 and data[4:8] == b"ftyp":
return True
return data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
def migu_z3d_stream_key(file_key: str) -> bytes:
"""streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制(32 字节 ASCII)。"""
key = str(file_key or "").strip()
if not key:
raise RuntimeError("空的 z3d fileKey")
# Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,这里同样禁用填充,否则尾块校验失败
decryptor = Cipher(algorithms.AES(_ANDROID_APP_KEY), modes.CBC(_Z3D_IV)).decryptor()
app_code = decryptor.update(_ENCRYPTED_APP_CODE) + decryptor.finalize()
digest = hashlib.md5(app_code + key.encode("utf-8")).hexdigest().upper()
return digest.encode("ascii")
def decrypt_z3d(data: bytes, file_key: str) -> bytes:
"""Z3D 流解密:逐字节 `plain[i] = cipher[i] - key[i % 32]`;已是明文则原样返回。"""
if not data:
raise RuntimeError("空的 z3d 数据")
if is_migu_audio_magic(data):
return data
payload = data[3:] if data[:3] == b"Z3D" else data
if not payload:
raise RuntimeError("剥离 Z3D 魔数后无数据")
stream_key = migu_z3d_stream_key(file_key)
plain = bytes((byte - stream_key[index & 31]) & 0xFF for index, byte in enumerate(payload))
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain