- 新增 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 路由验证通过
93 lines
3.9 KiB
Python
93 lines
3.9 KiB
Python
"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。
|
||
|
||
主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
|
||
响应形如 ``MusicJsonCallback({"code":0,"lyric":"<base64>"})``,lyric 为 base64 UTF-8 歌词。
|
||
实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。
|
||
兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。
|
||
解码:base64 → UTF-8 → HTML 实体(& 等)→ ``{ lyric, tlyric: "" }``。
|
||
|
||
QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from typing import Any
|
||
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.platforms.joox.music_info import build_track_url
|
||
from app.sdk.platforms.joox.util import (
|
||
JOOX_TIMEOUT,
|
||
decode_joox_lyric,
|
||
encode_uri_component,
|
||
first_non_empty,
|
||
joox_headers,
|
||
normalize_joox_id,
|
||
)
|
||
|
||
LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric"
|
||
|
||
|
||
def parse_lyric_payload(body: Any) -> dict[str, Any] | None:
|
||
"""解析 MusicJsonCallback(...) / 裸 JSON 包裹。"""
|
||
if isinstance(body, dict):
|
||
return body
|
||
text = str(body if body is not None else "")
|
||
index = text.find("MusicJsonCallback(")
|
||
if index >= 0:
|
||
text = text[index + len("MusicJsonCallback(") :]
|
||
text = text.rstrip()
|
||
if text.endswith(";"):
|
||
text = text[:-1].rstrip()
|
||
if text.endswith(")"):
|
||
text = text[:-1].rstrip()
|
||
try:
|
||
parsed = json.loads(text)
|
||
except ValueError:
|
||
return None
|
||
return parsed if isinstance(parsed, dict) else None
|
||
|
||
|
||
def parse_legacy_lyric(status_code: int, body: Any) -> str:
|
||
"""上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。"""
|
||
if status_code != 200:
|
||
raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})")
|
||
payload = parse_lyric_payload(body)
|
||
if not payload:
|
||
raise RuntimeError("joox 歌词解析失败")
|
||
raw = str(first_non_empty(payload.get("lyric"), ""))
|
||
if int(payload.get("code") or 0) != 0 or not raw:
|
||
raise RuntimeError("joox 歌词不存在或为空")
|
||
return decode_joox_lyric(raw)
|
||
|
||
|
||
async def get_legacy_lyric(song_id: str) -> str:
|
||
"""上游 GetLyrics 通道(仅旧接口,测试/内部用)。"""
|
||
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
|
||
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
|
||
return parse_legacy_lyric(res.status_code, res.body)
|
||
|
||
|
||
async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]:
|
||
"""歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。"""
|
||
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
|
||
if not song_id:
|
||
raise RuntimeError("joox 歌曲 id 无效")
|
||
|
||
# 1) 上游 web_lyric 通道
|
||
legacy_err: Exception | None = None
|
||
try:
|
||
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
|
||
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
|
||
return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""}
|
||
except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底
|
||
legacy_err = err
|
||
|
||
# 2) 兜底:openjoox2 详情 lrc_content(base64)
|
||
res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT)
|
||
body = res.body if isinstance(res.body, dict) else {}
|
||
raw = str(first_non_empty(body.get("lrc_content"), ""))
|
||
if res.status_code != 200 or not raw:
|
||
raise legacy_err or RuntimeError("joox 歌词不存在或为空")
|
||
return {"lyric": decode_joox_lyric(raw), "tlyric": ""}
|