- 新增 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 路由验证通过
146 lines
5.9 KiB
Python
146 lines
5.9 KiB
Python
"""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)
|