- 新增 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 路由验证通过
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""酷我歌词(对应 sdk/kw/lyric.ts)。
|
||
|
||
`newlyric.kuwo.cn/newlyric.lrc?<params>`:params 是明文字符串与 `yeelion` 逐字节异或
|
||
后的 base64。响应体是 zlib 压缩 + 可选 yeelion 异或,由 `lyric_decode` 还原。
|
||
|
||
歌词分两种形态:普通 lrc(`[mm:ss.xx]文本`)与 lrcx/逐字(每行带 `<start,dur>` 标记)。
|
||
lrcx 走 `util.parse_yrc` 归一到 `yrc` 字段,`lyric`/`tlyric` 里的逐字标记则剥掉,
|
||
保持纯 lrc 可直接给播放器。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.common.utils import decode_name
|
||
from app.sdk.platforms.kw.lyric_decode import handle_kw_decode_lyric
|
||
from app.sdk.platforms.kw.util import parse_yrc
|
||
|
||
_BUF_KEY = b"yeelion"
|
||
|
||
LYRIC_API = "http://newlyric.kuwo.cn/newlyric.lrc"
|
||
|
||
_TIME_EXP = re.compile(r"^\[([\d:.]*)\]")
|
||
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
|
||
_EXIST_TIME_EXP = re.compile(r"\[\d{1,2}:.*\d{1,4}\]")
|
||
_LYRICX_TAG = re.compile(r"^<-?\d+,-?\d+>")
|
||
_WORD_TIME_ALL = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
|
||
|
||
|
||
def build_params(songmid: str, is_get_lyricx: bool) -> str:
|
||
"""明文参数与 `yeelion` 循环异或后 base64。
|
||
|
||
原实现用 `new Uint16Array` 中转再 `Buffer.from(output)`;Node 对 TypedArray
|
||
走 fromArrayLike,按元素截断成 8 位字节,等价于这里的逐字节异或。
|
||
"""
|
||
params = f"user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_{songmid}"
|
||
if is_get_lyricx:
|
||
params += "&lrcx=1"
|
||
raw = params.encode()
|
||
return base64.b64encode(bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(raw))).decode()
|
||
|
||
|
||
def _sort_lrc_arr(arr: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||
"""按时间去重:重复时间视为翻译行,挤进 lrc_t。
|
||
|
||
全部行都没有逐字标记、且「翻译行」占比异常时,认为数据错乱并抛 failed。
|
||
"""
|
||
lrc_set: set[str] = set()
|
||
lrc: list[dict[str, str]] = []
|
||
lrc_t: list[dict[str, str]] = []
|
||
is_lyricx = False
|
||
for item in arr:
|
||
if item["time"] in lrc_set:
|
||
if len(lrc) < 2:
|
||
continue
|
||
t_item = lrc.pop()
|
||
t_item["time"] = lrc[-1]["time"]
|
||
lrc_t.append(t_item)
|
||
lrc.append(item)
|
||
else:
|
||
lrc.append(item)
|
||
lrc_set.add(item["time"])
|
||
if not is_lyricx and _LYRICX_TAG.match(item["text"]):
|
||
is_lyricx = True
|
||
if not is_lyricx and len(lrc_t) > len(lrc) * 0.3 and len(lrc) - len(lrc_t) > 6:
|
||
raise RuntimeError("failed")
|
||
return lrc, lrc_t
|
||
|
||
|
||
def _transform_lrc(tags: list[str], lrc_list: list[dict[str, str]]) -> str:
|
||
body = "".join(f"[{item['time']}]{item['text']}\n" for item in lrc_list)
|
||
return "\n".join(tags) + "\n" + body
|
||
|
||
|
||
def parse_lrc(lrc: str) -> dict[str, str]:
|
||
tags: list[str] = []
|
||
arr: list[dict[str, str]] = []
|
||
for raw_line in re.split(r"\r\n|\r|\n", lrc):
|
||
line = raw_line.strip()
|
||
match = _TIME_EXP.match(line)
|
||
if match:
|
||
text = _TIME_EXP.sub("", line, count=1).strip()
|
||
time = match.group(1)
|
||
# 只有两位小数的补一个 0,与三位/更长的保持同宽
|
||
if re.search(r"\.\d\d$", time):
|
||
time += "0"
|
||
arr.append({"time": time, "text": text})
|
||
elif _TAG_LINE.search(line):
|
||
tags.append(line)
|
||
|
||
lrc_list, lrc_t = _sort_lrc_arr(arr)
|
||
return {
|
||
"lyric": decode_name(_transform_lrc(tags, lrc_list)),
|
||
"tlyric": decode_name(_transform_lrc(tags, lrc_t)) if lrc_t else "",
|
||
}
|
||
|
||
|
||
async def get_lyric(song_info: dict[str, Any], is_get_lyricx: bool = True) -> dict[str, str]:
|
||
songmid = str(song_info.get("songmid") or "")
|
||
resp = await http_fetch(f"{LYRIC_API}?{build_params(songmid, is_get_lyricx)}")
|
||
if resp.status_code != 200:
|
||
raise RuntimeError(json.dumps(resp.body, ensure_ascii=False))
|
||
|
||
base64_data = await handle_kw_decode_lyric(base64.b64encode(resp.raw).decode(), is_get_lyricx)
|
||
try:
|
||
lrc_info = parse_lrc(base64.b64decode(base64_data).decode("utf-8", errors="replace"))
|
||
except Exception as err:
|
||
raise RuntimeError("Get lyric failed") from err
|
||
|
||
if lrc_info.get("tlyric"):
|
||
lrc_info["tlyric"] = _WORD_TIME_ALL.sub("", lrc_info["tlyric"])
|
||
# yrc 解析失败只代表这份歌词没有逐字数据,不影响普通歌词返回
|
||
try:
|
||
lrc_info["yrc"] = parse_yrc(lrc_info["lyric"])
|
||
except Exception: # noqa: BLE001
|
||
lrc_info["yrc"] = ""
|
||
lrc_info["lyric"] = _WORD_TIME_ALL.sub("", lrc_info["lyric"])
|
||
if not _EXIST_TIME_EXP.search(lrc_info["lyric"]):
|
||
raise RuntimeError("Get lyric failed")
|
||
return lrc_info
|