Files
MusicServer/app/http/mg_stream.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

196 lines
7.1 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.
"""咪咕加密流 —— 票据登记与 Z3D 流式解密(对应原 src/http/mg-stream.ts + sdk/mg/util.ts 解密部分)。
统一入口为 /stream/:source/:id,source=mg 时 id 即此处签发的短票据。
不把上游 URL 塞进播放地址(path 参数限长,且 URL 含时效签名与中文路径),
票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
Z3D 解密算法(与原始 TS 一致):
streamKey = MD5(AES128CBC(AndroidAppKey, ENCRYPTED_APP_CODE) + fileKey) 的大写十六进制(32 字节 ASCII)
plain[i] = (cipher[i] - streamKey[i % 32]) & 0xFF
密文可能以 3 字节 `Z3D` 魔数开头,需先剥离;已解密的数据(音频魔数开头)原样透传。
"""
from __future__ import annotations
import hashlib
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urljoin
import aiohttp
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
TTL_SECONDS = 10 * 60
STREAM_CACHE_LIMIT = 200
MIGU_STREAM_UA = "Android_migu/7.41.13 okhttp/3.12.13"
# 咪咕 Android 客户端的 app key 与加密后的 app code(服务端下发,非机密凭据)
_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))
@dataclass
class MiguStreamTicket:
url: str
file_key: str
created_at: float
_tickets: dict[str, MiguStreamTicket] = {}
def _prune() -> None:
now = time.time()
for key in [k for k, t in _tickets.items() if now - t.created_at > TTL_SECONDS]:
_tickets.pop(key, None)
# 超出容量时按插入顺序淘汰最旧票据
while len(_tickets) > STREAM_CACHE_LIMIT:
oldest = next(iter(_tickets), None)
if oldest is None:
break
_tickets.pop(oldest, None)
def register_migu_stream(url: str, file_key: str) -> str:
"""登记一条加密流,返回可直接放入 /stream/mg/:id 的短票据 id。"""
_prune()
ticket_id = uuid.uuid4().hex
_tickets[ticket_id] = MiguStreamTicket(url=url, file_key=file_key, created_at=time.time())
return ticket_id
def take_migu_stream(ticket_id: str) -> MiguStreamTicket | None:
"""取出票据(过期返回 None)。"""
ticket = _tickets.get(ticket_id)
if not ticket:
return None
if time.time() - ticket.created_at > TTL_SECONDS:
_tickets.pop(ticket_id, None)
return None
return ticket
def is_migu_audio_magic(data: bytes) -> bool:
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
if len(data) < 4:
return False
head4 = data[:4]
if head4 in (b"fLaC", b"OggS"):
return True
if head4 == 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:
"""整段解密(非流式场景;已是明文则原样返回)。"""
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
class Z3DStreamDecoder:
"""按块增量解密:跨块保持全局偏移,首块剥离 `Z3D` 魔数并校验结果。"""
def __init__(self, file_key: str) -> None:
self._stream_key = migu_z3d_stream_key(file_key)
self._offset = 0
self._pending = b"" # 首块不足 3 字节时暂存,用于判断 `Z3D` 魔数
self._checked = False
def feed(self, chunk: bytes) -> bytes:
if not chunk:
return b""
data = self._pending + chunk
self._pending = b""
if self._offset == 0 and len(data) < 3:
self._pending = data
return b""
if self._offset == 0 and data[:3] == b"Z3D":
data = data[3:]
base = self._offset
self._offset += len(data)
plain = bytes((byte - self._stream_key[(base + index) & 31]) & 0xFF for index, byte in enumerate(data))
if not self._checked and plain:
self._checked = True
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain
def migu_stream_content_type(url: str) -> str:
"""按扩展名推断明文的 content-type。"""
if ".flac" in url:
return "audio/flac"
if ".wav" in url:
return "audio/wav"
if ".m4a" in url:
return "audio/mp4"
return "audio/mpeg"
async def stream_decrypted(
url: str,
feed: Callable[[bytes], bytes],
on_chunk: Callable[[bytes], None],
*,
max_redirects: int = 3,
) -> None:
"""流式下载密流并逐块解密后交给 on_chunk。
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
"""
timeout = aiohttp.ClientTimeout(total=300, sock_read=60)
headers = {"User-Agent": MIGU_STREAM_UA, "Referer": "https://music.migu.cn/"}
current = url
for _ in range(max_redirects + 1):
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(current, headers=headers, allow_redirects=False) as resp:
if 300 <= resp.status < 400 and (location := resp.headers.get("location")):
current = urljoin(current, location)
continue
if resp.status != 200:
raise RuntimeError(f"上游返回 {resp.status}")
async for chunk in resp.content.iter_any():
plain = feed(chunk)
if plain:
on_chunk(plain)
return
raise RuntimeError("重定向次数过多")