Files
MusicServer/app/sdk/platforms/wy/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

248 lines
8.2 KiB
Python

"""网易云歌词(移植自 sdk/wy/lyric.ts,参考 Binaryify/NeteaseCloudMusicApi 的 module/lyric_new.js)。
上游返回 yrc(逐字)+ lrc(逐行)+ 各语种翻译/rlrc。逐字歌词的时间标签是
`[起始ms,时长ms](词起始ms,词时长ms,0)词` 形态,需要转换成标准 LRC + 富文本 `<偏移,时长>` 标签。
"""
from __future__ import annotations
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME
from app.sdk.platforms.wy.utils.crypto import eapi
RX_INFO = re.compile(r'^{"')
RX_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
RX_WORD_TIME = re.compile(r"\(\d+,\d+,\d+\)")
RX_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+,\d+\))")
RX_WORD_ITEM = re.compile(r"\((\d+),(\d+),\d+\)")
LYRIC_HOST = "https://interface3.music.163.com/eapi/song/lyric/v1"
def ms_format(time_ms: Any) -> str:
"""毫秒 → `[mm:ss.SSS]`。非数字返回空串(原实现用 Number.isNaN 判定)。"""
try:
total = int(float(time_ms))
except (TypeError, ValueError):
return ""
if total < 0:
return ""
ms = total % 1000
seconds = total // 1000
# 与原实现一致:毫秒不补齐(JS 的 `timeMs % 1000` 直接插值)
return f"[{seconds // 60:02d}:{seconds % 60:02d}.{ms}]"
def parse_lyric(lines: list[str]) -> dict[str, str]:
yrc_lines: list[str] = []
lrc_lines: list[str] = []
for raw_line in lines:
line = raw_line.strip()
match = RX_LINE_TIME.match(line)
if not match:
if line.startswith("[offset"):
yrc_lines.append(line)
lrc_lines.append(line)
continue
start_ms = int(match.group(1))
start_str = ms_format(start_ms)
if not start_str:
continue
words = RX_LINE_TIME.sub("", line)
lrc_lines.append(start_str + RX_WORD_TIME_ALL.sub("", words))
word_times = RX_WORD_TIME_ALL.findall(words)
if not word_times:
continue
tags = []
for chunk in word_times:
item = RX_WORD_ITEM.search(chunk)
if not item:
continue
tags.append(f"<{max(int(item.group(1)) - start_ms, 0)},{item.group(2)}>")
word_arr = RX_WORD_TIME.split(words)[1:] # 首段是第一个时间标签前的内容
new_words = "".join(
f"{tag}{word_arr[index] if index < len(word_arr) else ''}" for index, tag in enumerate(tags)
)
yrc_lines.append(f"{start_str}{new_words}")
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
def parse_header_info(text: Any) -> list[str] | None:
"""清洗歌词文本:去掉 \\r,把 `{"t":ms,"c":[{"tx":...}]}` 形式的逐字行转成标准 LRC 行。"""
if text is None:
return None
raw = str(text).strip().replace("\r", "")
if not raw:
return None
out: list[str] = []
for line in raw.split("\n"):
if not RX_INFO.match(line):
out.append(line)
continue
try:
info = json.loads(line)
except (ValueError, TypeError):
out.append("")
continue
tag = ms_format(info.get("t"))
if not tag:
out.append("")
continue
out.append(tag + "".join(str(chunk.get("tx", "")) for chunk in info.get("c") or []))
return out
def get_intv(interval: Any) -> int:
"""`mm:ss.SSS`(或 `hh:mm:ss.SSS`)→ 毫秒。"""
if not interval:
return 0
text = str(interval)
if "." not in text:
text += ".0"
parts = re.split(r"[:.]", text)
while len(parts) < 3:
parts.insert(0, "0")
minute, second, milli = parts[0], parts[1], parts[2]
def to_int(value: str) -> int:
try:
return int(value)
except ValueError:
return 0
return to_int(minute) * 3_600_000 + to_int(second) * 1000 + to_int(milli)
def fix_time_tag(lrc: str, target_lrc: str) -> str:
"""把 target 的文本按时间接近(<100ms)对齐到 lrc 的时间标签上。"""
rx_time = re.compile(r"^\[([\d:.]+)\]")
lrc_lines = lrc.split("\n")
new_lrc: list[str] = []
temp: list[str] = []
for line in target_lrc.split("\n"):
match = rx_time.match(line)
if not match:
continue
words = rx_time.sub("", line)
if not words.strip():
continue
t1 = get_intv(match.group(1))
while lrc_lines:
lrc_line = lrc_lines.pop(0)
lrc_match = rx_time.match(lrc_line)
if not lrc_match:
continue
t2 = get_intv(lrc_match.group(1))
if abs(t1 - t2) < 100:
merged = rx_time.sub(lrc_match.group(0), line).strip()
if not merged:
continue
new_lrc.append(merged)
break
temp.append(lrc_line)
lrc_lines = [*temp, *lrc_lines]
temp = []
return "\n".join(new_lrc)
def parse(y_lrc: Any, yt_lrc: Any, yr_lrc: Any, lrc: Any, tlrc: Any, rlrc: Any) -> dict[str, str]:
"""优先用逐字歌词(yrc)合成,缺失时退回普通 LRC。"""
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
if y_lrc:
lines = parse_header_info(y_lrc)
if lines:
result = parse_lyric(lines)
if yt_lrc:
t_lines = parse_header_info(yt_lrc)
if t_lines:
info["tlyric"] = fix_time_tag(result["lyric"], "\n".join(t_lines))
if yr_lrc:
r_lines = parse_header_info(yr_lrc)
if r_lines:
info["rlyric"] = fix_time_tag(result["lyric"], "\n".join(r_lines))
headers = "\n".join(line for line in lines if re.match(r"^\[[\d:.]+\]", line))
info["lyric"] = f"{headers}\n{result['lyric']}"
info["yrc"] = result["yrc"]
return info
for key, value in (("lyric", lrc), ("tlyric", tlrc), ("rlyric", rlrc)):
if not value:
continue
parsed_lines = parse_header_info(value)
if parsed_lines:
info[key] = "\n".join(parsed_lines)
return info
def fix_time_label(lrc: Any, tlrc: Any = None, romalrc: Any = None) -> tuple[Any, Any, Any]:
"""修正 `[mm:ss:SS]` 这类把毫秒写成第二冒号的错误时间标签(社区通行做法)。"""
if not lrc:
return lrc, tlrc, romalrc
text = str(lrc)
new_lrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", text)
new_tlrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", tlrc) if isinstance(tlrc, str) else tlrc
if new_lrc != text or new_tlrc != tlrc:
lrc, tlrc = new_lrc, new_tlrc
if isinstance(romalrc, str):
romalrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2,3})\]", r"[\1.\2]", romalrc)
romalrc = re.sub(r"\[(\d{2}:\d{2}\.\d{2})0\]", r"[\1]", romalrc)
return lrc, tlrc, romalrc
def _pick_lyric(block: Any) -> Any:
return block.get("lyric") if isinstance(block, dict) else None
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
"""取歌词。原文失败文案:Get lyric failed。"""
resp = await http_fetch(
LYRIC_HOST,
method="post",
headers={"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"},
form=eapi(
"/api/song/lyric/v1",
{
"id": song_info.get("songmid"),
"cp": False,
"tv": 0,
"lv": 0,
"rv": 0,
"kv": 0,
"yv": 0,
"ytv": 0,
"yrv": 0,
},
),
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200 or not _pick_lyric(body.get("lrc")):
raise RuntimeError("Get lyric failed")
lrc, tlrc, romalrc = fix_time_label(
_pick_lyric(body.get("lrc")), _pick_lyric(body.get("tlyric")), _pick_lyric(body.get("romalrc"))
)
info = parse(
_pick_lyric(body.get("yrc")),
_pick_lyric(body.get("ytlrc")),
_pick_lyric(body.get("yromalrc")),
lrc,
tlrc,
romalrc,
)
if not info["lyric"]:
raise RuntimeError("Get lyric failed")
return info