- 新增 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 路由验证通过
123 lines
4.6 KiB
Python
123 lines
4.6 KiB
Python
"""酷狗音乐 取流(对应 sdk/apis/kg.ts)。
|
||
|
||
通道:`https://gateway.kugou.com/v5/url`(x-router: trackercdn.kugou.com,android 签名)。
|
||
登录后自动带 token/userid(见 /login/kg);匿名仅能取部分免费资源,VIP/受限资源返回 status:2。
|
||
音质映射:128k→128、320k→320、flac→flac、flac24bit→high。
|
||
成功响应播放地址在顶层 url: string[] 与 backupUrl: string[];旧 data:[{url}] 仅作兼容兜底。
|
||
album_audio_id 传 NaN 会被网关拒答,必须归零。
|
||
|
||
注意:TS 原实现在这里 `getDevice()` + `androidSign`,但那两个函数在 `sdk/auth/kg.ts`,
|
||
Python 侧 `app/sdk/auth/kg.py` 仍是 stub(不在本次写入范围),因此设备指纹与签名
|
||
改为 `app/sdk/platforms/kg/util.py` 的实现 —— 算法逐字一致,指纹同样落在
|
||
`auth.json` 的 `_kg_device`,与登录态共用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import math
|
||
import time
|
||
from typing import Any
|
||
|
||
from app.sdk.auth_store import get_kg_auth
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.platforms.kg.util import android_sign, encode_uri_component, get_device
|
||
|
||
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
|
||
|
||
SALT_KEY = "57ae12eb6890223e355ccfcb74edf70d"
|
||
APPID = 1005
|
||
CLIENTVER = 11430
|
||
|
||
QUALITY_MAP = {"128k": "128", "320k": "320", "flac": "flac", "flac24bit": "high"}
|
||
|
||
URL_API = "https://gateway.kugou.com/v5/url"
|
||
|
||
|
||
def _num(value: Any) -> int:
|
||
"""JS 的 Number() 语义:不可解析或非有限值一律归零(网关拒答 NaN)。"""
|
||
try:
|
||
number = float(value)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
return int(number) if math.isfinite(number) else 0
|
||
|
||
|
||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||
types = song_info.get("_types") or {}
|
||
quality_info = types.get(quality) if isinstance(types, dict) else None
|
||
hash_value = str((quality_info or {}).get("hash") or song_info.get("hash") or "").lower()
|
||
if not hash_value:
|
||
raise RuntimeError("缺少 hash")
|
||
|
||
dev = get_device()
|
||
auth = get_kg_auth()
|
||
userid = _num((auth or {}).get("userid") or 0)
|
||
|
||
params: dict[str, Any] = {
|
||
"dfid": dev["dfid"],
|
||
"mid": dev["mid"],
|
||
"uuid": "-",
|
||
"appid": APPID,
|
||
"clientver": CLIENTVER,
|
||
"clienttime": int(time.time()),
|
||
"album_id": _num(song_info.get("albumId")),
|
||
"area_code": 1,
|
||
"hash": hash_value,
|
||
"ssa_flag": "is_fromtrack",
|
||
"version": CLIENTVER,
|
||
"page_id": 151369488,
|
||
"quality": QUALITY_MAP.get(quality, "128"),
|
||
"album_audio_id": _num(song_info.get("songmid")),
|
||
"behavior": "play",
|
||
"pid": 2,
|
||
"cmd": 26,
|
||
"pidversion": 3001,
|
||
"IsFreePart": 0,
|
||
"ppage_id": "463467626,350369493,788954147",
|
||
"cdnBackup": 1,
|
||
"module": "",
|
||
}
|
||
if auth:
|
||
params["token"] = auth["token"]
|
||
params["userid"] = userid
|
||
params["key"] = hashlib.md5(f"{hash_value}{SALT_KEY}{APPID}{dev['mid']}{userid}".encode()).hexdigest()
|
||
params["signature"] = android_sign(params)
|
||
|
||
qs = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
|
||
resp = await http_fetch(
|
||
f"{URL_API}?{qs}",
|
||
method="get",
|
||
headers={
|
||
"dfid": dev["dfid"],
|
||
"mid": dev["mid"],
|
||
"clienttime": str(params["clienttime"]),
|
||
"kg-rc": "1",
|
||
"kg-thash": "5d816a0",
|
||
"kg-rec": "1",
|
||
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
|
||
"x-router": "trackercdn.kugou.com",
|
||
"User-Agent": "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi",
|
||
},
|
||
timeout=15000,
|
||
)
|
||
|
||
body = resp.body if isinstance(resp.body, dict) else {}
|
||
if body.get("status") != 1:
|
||
raise RuntimeError(str(body.get("error") or "获取失败(该资源需要酷狗登录)"))
|
||
|
||
candidates: list[Any] = []
|
||
if isinstance(body.get("url"), list):
|
||
candidates.extend(body["url"])
|
||
if isinstance(body.get("backupUrl"), list):
|
||
candidates.extend(body["backupUrl"])
|
||
url = next((str(item) for item in candidates if item), "")
|
||
if not url and isinstance(body.get("data"), list):
|
||
for entry in body["data"]:
|
||
if isinstance(entry, dict) and (entry.get("url") or entry.get("fallback")):
|
||
url = str(entry.get("url") or entry.get("fallback"))
|
||
break
|
||
if not url:
|
||
raise RuntimeError("获取失败(网关未返回播放地址)")
|
||
return {"type": quality, "url": url}
|