Files
MusicServer/app/http/mg_stream.py
T
snowy-yang 0df18f048b fix(server): 修复路由遮蔽、流式背压与鉴权限流顺序
- 专辑搜索静态路由优先注册,避免被 /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 与文档
2026-10-23 23:40:55 +08:00

198 lines
7.3 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 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("重定向次数过多")