Files
MusicServer/app/sdk/apis/tx.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

146 lines
5.9 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.
"""QQ音乐取流(移植自 sdk/apis/tx.ts)。
接口:musicu.fcg 的 vkey.GetVkeyServer/CgiGetVkey。
取流方案(与原文一致):
* 一次请求带上整条音质候选链的 filename,命中第一个有 purl 的档位即「最高可用音质」;
* 始终附带 `C400{songmid}.m4a` 保底 —— 匿名账户唯一能拿到的 128k AAC;
* 档位前缀:RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC > F000 无损 FLAC >
M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
* VIP 档 filename 用 media_mid(songmid ≠ media_mid 时用媒体 id),保底档用 songmid;
* 登录 cookie(uin + qm_keyst,见 /login/tx)才可能解锁 320k/无损/Hi-Res,
未登录时高档位 purl 为空,只能落到 C400 或 testfile2g 试听地址。
参数映射:路由的 song_info 只有 {"songmid", "albumId", ...},没有 media_mid。
因此先查 songmid → media_mid(musicu 的 pf_song_detail_svr),查不到就退回用 songmid
(与原文 `songInfo.strMediaMid || songInfo.songmid` 同义)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils import MUSICU_URL
# 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
_CANDIDATES: dict[str, list[tuple[str, str, str]]] = {
"128k": [("M500", "mp3", "128k")],
"320k": [("M800", "mp3", "320k"), ("M500", "mp3", "128k")],
"flac": [("F000", "flac", "flac"), ("M800", "mp3", "320k"), ("M500", "mp3", "128k")],
"flac24bit": [
("RS01", "flac", "flac24bit"),
("Q000", "flac", "flac24bit"),
("AI00", "flac", "flac24bit"),
("F000", "flac", "flac"),
("M800", "mp3", "320k"),
("M500", "mp3", "128k"),
],
}
SUPPORT_QUALITYS: list[str] = list(_CANDIDATES)
_UA = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
_mid_cache: dict[str, str] = {}
async def _resolve_media_mid(songmid: str) -> str:
"""songmid → media_mid。失败/缺失时返回空串,由调用方退回 songmid。"""
cached = _mid_cache.get(songmid)
if cached is not None:
return cached
media_mid = ""
try:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"module": "music.pf_song_detail_svr",
"method": "get_song_detail_yqq",
"param": {"song_type": 0, "song_mid": songmid},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
track = ((body.get("req") or {}).get("data") or {}).get("track_info") or {}
media_mid = str((track.get("file") or {}).get("media_mid") or "")
except (RuntimeError, OSError, TimeoutError):
# 查不到不阻塞取流:退回 songmid(无名曲目/接口抖动时的降级行为)
return ""
_mid_cache[songmid] = media_mid
return media_mid
def _build_entries(song_info: dict[str, Any], quality: str, media_mid: str) -> list[dict[str, str]]:
songmid = str(song_info.get("songmid") or "")
entries: list[dict[str, str]] = []
for prefix, ext, hit_type in _CANDIDATES.get(quality) or _CANDIDATES["128k"]:
entries.append({"filename": f"{prefix}{media_mid}.{ext}", "type": hit_type})
# 保底:匿名 128k AAC 用 songmid(不是 media_mid)
entries.append({"filename": f"C400{songmid}.m4a", "type": "128k"})
return entries
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。返回 {"type": 实际命中音质, "url": 播放地址}(type 可能低于请求值)。
失败文案与原文一致:`获取失败`。
"""
if retry_num > 2:
raise RuntimeError("获取失败")
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取失败")
media_mid = str(song_info.get("strMediaMid") or "") or await _resolve_media_mid(songmid) or songmid
entries = _build_entries(song_info, quality, media_mid)
auth = get_tx_auth()
uin = str(auth.get("uin") or "0") if auth else "0"
headers = {"User-Agent": _UA, "Referer": "https://y.qq.com/"}
if auth and auth.get("cookie"):
headers["cookie"] = str(auth["cookie"])
data = {
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {
"module": "vkey.GetVkeyServer",
"method": "CgiGetVkey",
"param": {
"guid": "7342827269",
"songmid": [songmid],
"filename": [entry["filename"] for entry in entries],
"songtype": [0],
"uin": uin,
"loginflag": 1,
"platform": "20",
"needNewCode": 1,
},
},
}
resp = await http_fetch(MUSICU_URL, method="post", headers=headers, body=data, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
return await get_music_url(song_info, quality, retry_num + 1)
payload = req.get("data") or {}
sip = (payload.get("sip") or ["http://aqqmusic.tc.qq.com/"])[0]
infos = payload.get("midurlinfo") or []
for index, info in enumerate(infos):
if info.get("purl"):
hit_type = entries[index]["type"] if index < len(entries) else "128k"
return {"type": hit_type, "url": f"{sip}{info['purl']}"}
# 全部受限(VIP 歌匿名)时回退服务器给的 128k m4a 试听文件
if payload.get("testfile2g"):
return {"type": "128k", "url": f"{sip}{payload['testfile2g']}"}
return await get_music_url(song_info, quality, retry_num + 1)