- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
198 lines
7.3 KiB
Python
198 lines
7.3 KiB
Python
"""咪咕加密流 —— 票据登记与 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 Awaitable, 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], Awaitable[None]],
|
||
*,
|
||
max_redirects: int = 3,
|
||
) -> None:
|
||
"""流式下载密流并逐块解密后交给 on_chunk。
|
||
|
||
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
|
||
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
|
||
on_chunk 为 async,调用方可用有界队列形成背压,避免慢客户端时丢块/死锁。
|
||
"""
|
||
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):
|
||
# trust_env=False:与 common/request 保持一致,不读系统代理(Windows 上 aiohttp 会走 IE 设置)
|
||
async with aiohttp.ClientSession(timeout=timeout, trust_env=False) 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:
|
||
await on_chunk(plain)
|
||
return
|
||
raise RuntimeError("重定向次数过多")
|