- 新增 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 路由验证通过
297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""千千音乐(music.91q.com / 91q.com)公共请求层(对应 sdk/bd/util.ts)。
|
||
|
||
上游全部接口都走 `/v1/*`,参数 = 业务参数 + appid + timestamp + sign:
|
||
|
||
sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
||
|
||
匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 只影响 VIP、收藏等登录态资源。
|
||
|
||
**签名串与 query 必须分开编码**:sign 用参数原值拼接(服务端拿收到的参数值重算),
|
||
query 用 `URLSearchParams` 序列化。两者共用同一份 `sign_params` 结果,且 URL 拼好后
|
||
直接交给 `http_fetch`(不再二次序列化),这是历史上「签名串 ≠ 实际 body」踩过的坑。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import hashlib
|
||
import math
|
||
import re
|
||
import time
|
||
from typing import Any
|
||
|
||
from app.sdk.auth_store import get_cookie
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.common.utils import size_formate
|
||
|
||
APP_ID = "16073360"
|
||
SECRET = "0b50b02fd0d73a9c4c8c3a781c30845f"
|
||
USER_AGENT = (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||
)
|
||
REFERER = "https://music.91q.com/player"
|
||
API_BASE = "https://music.91q.com"
|
||
|
||
# 上游音质档位顺序(3000 = flac,其余为 kbps)
|
||
RATE_ORDER = ["3000", "320", "128", "64"]
|
||
|
||
# URLSearchParams 不编码的字符集。与 Python quote 的默认安全字符不同:
|
||
# URLSearchParams 保留 `*` 却编码 `~`,差一个字符服务端就验签失败,故自行实现。
|
||
_FORM_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._")
|
||
|
||
# rateFileInfo 档位 → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
|
||
_RATE_LABEL = {"128": "128k", "320": "320k", "3000": "flac"}
|
||
|
||
# 上游 sanitizeQianqianAlbumKeyword 的特殊字符集
|
||
_ALBUM_KEYWORD_SPECIALS = re.compile(r"""[::"'“”‘’()()\[\]【】,,/\\\-.]""")
|
||
|
||
|
||
def to_number(value: Any) -> float:
|
||
"""JS `Number()` 语义:None(undefined) 与不可解析 → NaN,bool → 1/0,空串 → 0。"""
|
||
if value is None:
|
||
return math.nan
|
||
if isinstance(value, bool):
|
||
return 1.0 if value else 0.0
|
||
if isinstance(value, (int, float)):
|
||
return float(value)
|
||
text = str(value).strip()
|
||
if not text:
|
||
return 0.0
|
||
try:
|
||
return float(text)
|
||
except ValueError:
|
||
return math.nan
|
||
|
||
|
||
def num_or_zero(value: Any) -> float:
|
||
"""`Number(value ?? 0)` 语义:缺失(null/undefined)视作 0,不可解析才是 NaN。"""
|
||
return 0.0 if value is None else to_number(value)
|
||
|
||
|
||
def to_int(value: Any, fallback: int = 0) -> int:
|
||
"""`Number(value) || fallback` 语义:0 / NaN / 不可解析都落到 fallback。"""
|
||
number = to_number(value)
|
||
if not number or math.isnan(number):
|
||
return fallback
|
||
return int(number)
|
||
|
||
|
||
def form_encode(value: Any) -> str:
|
||
"""`new URLSearchParams(...).toString()` 的逐字节等价实现(空格写 `+`,其余按 UTF-8 编码)。"""
|
||
out: list[str] = []
|
||
for byte in str(value).encode("utf-8"):
|
||
char = chr(byte)
|
||
if char in _FORM_SAFE:
|
||
out.append(char)
|
||
elif char == " ":
|
||
out.append("+")
|
||
else:
|
||
out.append(f"%{byte:02X}")
|
||
return "".join(out)
|
||
|
||
|
||
def get_qian_cookie() -> str:
|
||
"""千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致)。"""
|
||
return get_cookie("bd")
|
||
|
||
|
||
def request_headers() -> dict[str, str]:
|
||
headers = {
|
||
"User-Agent": USER_AGENT,
|
||
"Referer": REFERER,
|
||
"Accept": "application/json, text/plain, */*",
|
||
}
|
||
cookie = get_qian_cookie()
|
||
if cookie:
|
||
headers["Cookie"] = cookie
|
||
return headers
|
||
|
||
|
||
def sign_params(params: dict[str, Any]) -> dict[str, Any]:
|
||
"""写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5。"""
|
||
signed: dict[str, Any] = {str(key): value for key, value in params.items()}
|
||
signed["timestamp"] = str(int(time.time()))
|
||
payload = "&".join(f"{key}={signed[key]}" for key in sorted(signed)) + SECRET
|
||
signed["sign"] = hashlib.md5(payload.encode("utf-8")).hexdigest()
|
||
return signed
|
||
|
||
|
||
def signed_url(path: str, params: dict[str, Any]) -> str:
|
||
"""签名后的完整 URL:签名用原值,query 用 URLSearchParams 等价编码。"""
|
||
query = "&".join(f"{key}={form_encode(value)}" for key, value in sign_params(params).items())
|
||
return f"{API_BASE}{path}?{query}"
|
||
|
||
|
||
async def api_get(path: str, params: dict[str, Any]) -> Any:
|
||
"""发起带签名的 GET,返回响应 JSON body(非 200 按原文案抛错)。"""
|
||
resp = await http_fetch(signed_url(path, params), method="get", headers=request_headers(), timeout=15000)
|
||
if resp.status_code != 200:
|
||
raise RuntimeError(f"千千音乐接口失败(HTTP {resp.status_code})")
|
||
return resp.body if resp.body is not None else {}
|
||
|
||
|
||
def api_ok(body: Any) -> bool:
|
||
"""上游状态判定:state=true 或 errno=22000 视为成功。"""
|
||
if not isinstance(body, dict):
|
||
return False
|
||
return body.get("state") is True or to_number(body.get("errno")) == 22000
|
||
|
||
|
||
def error_text(body: Any, prefix: str) -> str:
|
||
"""上游错误文案:errmsg 优先,缺失时拼 errno(两种文案都逐字沿用原实现)。"""
|
||
if not isinstance(body, dict):
|
||
return prefix
|
||
message = str(body.get("errmsg") or "").strip()
|
||
if message:
|
||
return message
|
||
return f"{prefix}(errno {body.get('errno')})"
|
||
|
||
|
||
async def track_link(tsid: str, rate: str) -> Any:
|
||
"""tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path)。"""
|
||
return await api_get("/v1/song/tracklink", {"TSID": tsid, "appid": APP_ID, "rate": rate})
|
||
|
||
|
||
def interval_of(duration: Any) -> str | int:
|
||
"""时间格式 mm:ss(duration 为秒;无效或 <=0 显示 0)。"""
|
||
seconds = to_number(duration)
|
||
if math.isnan(seconds) or seconds <= 0:
|
||
return 0
|
||
return f"{int(seconds // 60):02d}:{int(seconds % 60):02d}"
|
||
|
||
|
||
def join_artists(artists: Any) -> str:
|
||
"""千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接)。"""
|
||
if not isinstance(artists, list) or not artists:
|
||
return ""
|
||
names: list[str] = []
|
||
seen: set[str] = set()
|
||
|
||
def add(name: Any) -> None:
|
||
text = "" if name is None else str(name).strip()
|
||
if not text or text in seen:
|
||
return
|
||
seen.add(text)
|
||
names.append(text)
|
||
|
||
for item in artists:
|
||
if isinstance(item, dict) and to_number(item.get("artistType")) == 38:
|
||
add(item.get("name"))
|
||
if not names:
|
||
for item in artists:
|
||
if isinstance(item, dict):
|
||
add(item.get("name"))
|
||
return "、".join(names)
|
||
|
||
|
||
def normalize_album_asset_code(id: Any) -> str:
|
||
"""上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P。"""
|
||
text = ("" if id is None else str(id)).strip()
|
||
if len(text) < 2:
|
||
return ""
|
||
return f"P{text[1:]}" if text[0] in ("p", "P") else ""
|
||
|
||
|
||
def sanitize_album_keyword(keyword: Any) -> str:
|
||
"""上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(23001 时重试用)。"""
|
||
trimmed = ("" if keyword is None else str(keyword)).strip()
|
||
if not trimmed:
|
||
return ""
|
||
return " ".join(part for part in _ALBUM_KEYWORD_SPECIALS.sub(" ", trimmed).split() if part)
|
||
|
||
|
||
def first_non_empty(*values: Any) -> str:
|
||
for value in values:
|
||
text = "" if value is None else str(value).strip()
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
|
||
def release_date10(raw: Any) -> str:
|
||
"""上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD)。"""
|
||
text = ("" if raw is None else str(raw)).strip()
|
||
return text[:10] if len(text) >= 10 else text
|
||
|
||
|
||
def rate_stats(rate_file_info: Any, duration: float) -> dict[str, int]:
|
||
"""上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps)。"""
|
||
for rate in RATE_ORDER:
|
||
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
|
||
if not isinstance(info, dict):
|
||
continue
|
||
size = to_number(info.get("size"))
|
||
if not size > 0:
|
||
continue
|
||
if duration > 0:
|
||
return {"size": int(size), "bitrate": int(size * 8 / 1000 / duration)}
|
||
return {"size": int(size), "bitrate": 800 if rate == "3000" else int(rate)}
|
||
return {"size": 0, "bitrate": 0}
|
||
|
||
|
||
def rate_types(rate_file_info: Any) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||
"""rateFileInfo → 既有 Quality 桶的 types / _types。"""
|
||
types: list[dict[str, Any]] = []
|
||
type_map: dict[str, dict[str, Any]] = {}
|
||
for rate in ("128", "320", "3000"):
|
||
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
|
||
if not isinstance(info, dict):
|
||
continue
|
||
size = to_number(info.get("size"))
|
||
if not size > 0:
|
||
continue
|
||
label = _RATE_LABEL[rate]
|
||
size_text = size_formate(int(size))
|
||
types.append({"type": label, "size": size_text})
|
||
type_map[label] = {"size": size_text}
|
||
return types, type_map
|
||
|
||
|
||
def map_track(
|
||
item: Any,
|
||
*,
|
||
album_id: str = "",
|
||
album_name: str = "",
|
||
img: str | None = None,
|
||
singer: str = "",
|
||
) -> dict[str, Any]:
|
||
"""千千曲目字段 → MusicServer 统一 Song 形状。
|
||
|
||
搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
|
||
歌单详情用 TSID + albumTitle + pic。
|
||
注:上游 Search 会跳过 isVip != 0 的曲目;本实现保留它们并标记 payPlay,
|
||
取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
|
||
"""
|
||
source = item if isinstance(item, dict) else {}
|
||
duration = num_or_zero(source.get("duration"))
|
||
stats = rate_stats(source.get("rateFileInfo"), duration)
|
||
types, type_map = rate_types(source.get("rateFileInfo"))
|
||
return {
|
||
"songmid": first_non_empty(source.get("TSID"), source.get("assetId"), source.get("tsid")),
|
||
"name": str(source.get("title") or "").strip(),
|
||
"singer": first_non_empty(join_artists(source.get("artist")), singer),
|
||
"albumId": first_non_empty(normalize_album_asset_code(source.get("albumAssetCode")), album_id),
|
||
"albumName": first_non_empty(source.get("albumTitle"), album_name),
|
||
"interval": interval_of(duration),
|
||
"size": stats["size"] or 0,
|
||
"bitrate": stats["bitrate"] or 0,
|
||
"payPlay": num_or_zero(source.get("isVip")) != 0 or num_or_zero(source.get("isPaid")) != 0,
|
||
"img": first_non_empty(source.get("pic"), img) or None,
|
||
"lrc": None,
|
||
"lrcx": "",
|
||
"types": types,
|
||
"_types": type_map,
|
||
"source": "bd",
|
||
}
|
||
|
||
|
||
def song_link(tsid: str) -> str:
|
||
return f"https://music.91q.com/song/{tsid}"
|
||
|
||
|
||
def album_link(id: str) -> str:
|
||
return f"https://music.91q.com/album/{id}"
|
||
|
||
|
||
def playlist_link(id: str) -> str:
|
||
return f"https://music.91q.com/songlist/{id}"
|