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

277 lines
9.5 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/tx/lyric.ts)。
上游返回 QRC(hex 密文),需 decode_qrc 解密后是逐字时间轴格式:
[起始毫秒,持续毫秒]字(起始,持续)字(起始,持续)...
同一份数据要拆成两路:
lyric —— 标准 LRC(把逐字标记剥掉,只留行时间)
yrc —— 逐字歌词(`<相对起始,持续>` 标记 + 文字)
翻译(trans)与罗马音(roma)的对齐由 fix*_time_tag 按 ±100ms 容差匹配行时间。
"""
from __future__ import annotations
import asyncio
import re
from collections.abc import Callable
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
from app.sdk.platforms.tx.qrc_decode import decode_qrc
from app.sdk.platforms.tx.utils import MUSICU_URL
SUCCESS_CODE = 0
_UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
_LINE_TIME2 = re.compile(r"^\[([\d:.]+)\]")
_WORD_TIME = re.compile(r"\(\d+,\d+\)")
_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+\))")
_WORD_TIME_PARTS = re.compile(r"\((\d+),(\d+)\)")
_TIME_SPLIT = re.compile(r"[:.]")
_TAG_HEAD = re.compile(r'^[\S\s]*?LyricContent="')
_TAG_TAIL = re.compile(r'"\/>[\S\s]*?$')
# songmid → songId 缓存;promises 用于合并并发请求(同一首歌并发取歌词只查一次详情)
_song_id_map: dict[str, Any] = {}
_promises: dict[str, asyncio.Task[Any]] = {}
def ms_format(time_ms: int) -> str:
"""毫秒 → `[mm:ss.SSS]`。"""
ms = time_ms % 1000
total = time_ms // 1000
minute = total // 60
second = total % 60
return f"[{minute:02d}:{second:02d}.{ms:03d}]"
def parse_lyric(lrc: str) -> dict[str, str]:
"""逐字 QRC 正文 → {lyric: 标准 LRC, yrc: 逐字歌词}。"""
text = lrc.strip().replace("\r", "")
if not text:
return {"lyric": "", "yrc": ""}
yrc_lines: list[str] = []
lrc_lines: list[str] = []
for raw_line in text.split("\n"):
line = raw_line.strip()
result = _LINE_TIME.search(line)
if not result:
if line.startswith("[offset"):
yrc_lines.append(line)
lrc_lines.append(line)
if _LINE_TIME2.match(line):
lrc_lines.append(line)
continue
start_ms = int(result.group(1))
start_str = ms_format(start_ms)
words = _LINE_TIME.sub("", line, count=1)
lrc_lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
times = _WORD_TIME_ALL.findall(words)
if not times:
continue
marks = []
for time in times:
parts = _WORD_TIME_PARTS.fullmatch(time)
if parts is None:
continue
marks.append(f"<{max(int(parts.group(1)) - start_ms, 0)},{parts.group(2)}>")
word_arr = _WORD_TIME.split(words)
new_words = "".join(
f"{mark}{word_arr[index] if index < len(word_arr) else ''}" for index, mark in enumerate(marks)
)
yrc_lines.append(f"{start_str}{new_words}")
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
def parse_rlyric(lrc: str) -> str:
"""罗马音(逐字格式)→ 标准 LRC 行。"""
text = lrc.strip().replace("\r", "")
if not text:
return ""
lines: list[str] = []
for raw_line in text.split("\n"):
line = raw_line.strip()
result = _LINE_TIME.search(line)
if not result:
continue
start_str = ms_format(int(result.group(1)))
words = _LINE_TIME.sub("", line, count=1)
lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
return "\n".join(lines)
def remove_tag(text: str) -> str:
"""剥离 `<LyricContent="..." />` 外层标签,只留正文。"""
return _TAG_TAIL.sub("", _TAG_HEAD.sub("", text))
def get_intv(interval: str) -> int:
"""`mm:ss.SSS` / `m:ss` → 毫秒(沿用原实现的 3600000 系数,两侧比较口径一致)。"""
if not interval:
return 0
if "." not in interval:
interval += ".0"
parts = _TIME_SPLIT.split(interval)
while len(parts) < 3:
parts.insert(0, "0")
minute, second, ms = parts[0], parts[1], parts[2]
def _int(value: str) -> int:
try:
return int(value)
except ValueError:
return 0
return _int(minute) * 3600000 + _int(second) * 1000 + _int(ms)
def _align_times(raw_lyric: str, lrc: str) -> str:
"""把 raw_lyric 的行时间替换成 lrc 中时间最近的标签(±100ms 内才算命中)。
与原文一致:lrc 行是一次性指针,未命中的行被丢弃(不做回补)。
"""
out: list[str] = []
lrc_lines = lrc.split("\n")
for line in raw_lyric.split("\n"):
result = _LINE_TIME2.match(line)
if not result:
continue
if not _LINE_TIME2.sub("", line, count=1).strip():
continue
t1 = get_intv(result.group(1))
while lrc_lines:
lrc_line = lrc_lines.pop(0)
lrc_result = _LINE_TIME2.match(lrc_line)
if not lrc_result:
continue
if abs(t1 - get_intv(lrc_result.group(1))) < 100:
out.append(_LINE_TIME2.sub(_literal(lrc_result.group(0)), line, count=1))
break
return "\n".join(out)
def _literal(text: str) -> Callable[[re.Match[str]], str]:
r"""替换串按字面量处理(`\` 与组引用需转义;时间标签本身不带这些字符,保持严格)。"""
return lambda _match: text
def fix_rlrc_time_tag(rlrc: str, lrc: str) -> str:
return _align_times(rlrc, lrc)
def fix_tlrc_time_tag(tlrc: str, lrc: str) -> str:
"""翻译行对齐。
原 TS 打算把 `mm:ss.d` 的小数位补齐到 3 位,但写的是
`time += ''.padStart(3 - n, '0')` —— 对空串补位恒为空,实际是空操作。
这里保持同样的换算口径(不补位),否则与 TS 版的 tlyric 对齐结果不一致。
"""
return _align_times(tlrc, lrc)
def parse(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
if lrc:
parsed = parse_lyric(remove_tag(lrc))
info["lyric"] = parsed["lyric"]
info["yrc"] = parsed["yrc"]
if rlrc:
info["rlyric"] = fix_rlrc_time_tag(parse_rlyric(remove_tag(rlrc)), info["lyric"])
if tlrc:
info["tlyric"] = fix_tlrc_time_tag(tlrc, info["lyric"])
return info
async def parse_lyric_hex(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
"""三路 QRC hex 解密后解析。"""
lyric, tlyric, rlyric = decode_qrc(lrc), decode_qrc(tlrc), decode_qrc(rlrc)
return parse(lyric, tlyric, rlyric)
async def get_song_id(song_info: dict[str, Any]) -> Any:
"""songId 优先;否则用 songmid 反查(带缓存 + 并发合并)。"""
song_id = song_info.get("songId")
if song_id:
return song_id
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取歌曲信息失败")
if songmid in _song_id_map:
return _song_id_map[songmid]
task = _promises.get(songmid)
if task is None:
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
_promises[songmid] = task
try:
info = await asyncio.shield(task)
finally:
_promises.pop(songmid, None)
if not info:
raise RuntimeError("获取歌曲信息失败")
_song_id_map[songmid] = info.get("songId")
return info.get("songId")
async def _fetch(song_id: Any, retry_num: int) -> dict[str, str]:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"referer": "https://y.qq.com", "user-agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"method": "GetPlayLyricInfo",
"module": "music.musichallSong.PlayLyricInfo",
"param": {
"format": "json",
"crypt": 1,
"ct": 19,
"cv": 1873,
"interval": 0,
"lrc_t": 0,
"qrc": 1,
"qrc_t": 0,
"roma": 1,
"roma_t": 0,
"songID": song_id,
"trans": 1,
"trans_t": 0,
"type": -1,
},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
data = req.get("data") or {}
if body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
return await _retry(song_id, retry_num)
return await parse_lyric_hex(data.get("lyric"), data.get("trans"), data.get("roma"))
async def _retry(song_id: Any, retry_num: int) -> dict[str, str]:
"""失败重试(上限 3 次,文案与原文一致:Get lyric failed)。"""
retry_num += 1
if retry_num > 3:
raise RuntimeError("Get lyric failed")
# 原实现在此处把 songId 当 song_info 再走一次 getSongId(会解析成空 mid 而必然失败),
# 这里直接复用已取到的 songId 重发同一请求,降级链(同接口重试)保持一致。
return await _fetch(song_id, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
"""Platform.get_lyric 契约:song_info → {lyric, tlyric, rlyric, yrc}。"""
song_id = await get_song_id(song_info)
return await _fetch(song_id, 0)