- 新增 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 路由验证通过
79 lines
3.4 KiB
Python
79 lines
3.4 KiB
Python
"""猫耳FM URL 获取(对应 sdk/apis/me.ts,App API 通道)。
|
||
|
||
链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
|
||
soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
|
||
soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
|
||
|
||
实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
|
||
付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带。
|
||
|
||
songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出);
|
||
"d:<drama_id>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.common.utils import as_dict, as_list
|
||
from app.sdk.platforms.me.music_info import get_sound_info
|
||
|
||
#: 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
|
||
QUALITY_FIELDS: dict[str, list[str]] = {
|
||
"128k": ["soundurl_128", "soundurl", "soundurl_64", "soundurl_32"],
|
||
"64k": ["soundurl_64", "soundurl", "soundurl_128", "soundurl_32"],
|
||
}
|
||
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
|
||
|
||
|
||
def pick_url(info: dict[str, Any], quality: str) -> dict[str, str]:
|
||
fields = QUALITY_FIELDS.get(quality, QUALITY_FIELDS["128k"])
|
||
url = ""
|
||
for field in fields:
|
||
value = info.get(field)
|
||
if value and str(value).startswith("http"):
|
||
url = str(value)
|
||
break
|
||
if not url:
|
||
raise RuntimeError("无可用音频资源(付费内容需 POST /login/me 绑定 token)")
|
||
hit_128 = "-128k" in url or quality == "128k"
|
||
return {"type": "128k" if hit_128 else "64k", "url": url}
|
||
|
||
|
||
async def _resolve_drama_first_free(drama_id: str) -> str:
|
||
"""剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[])。"""
|
||
resp = await http_fetch(
|
||
f"https://www.missevan.com/dramaapi/getdrama?drama_id={drama_id}",
|
||
headers={
|
||
"User-Agent": (
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
||
),
|
||
"Accept": "application/json",
|
||
"Referer": f"https://www.missevan.com/mdrama/{drama_id}",
|
||
},
|
||
timeout=15_000,
|
||
)
|
||
info = as_dict(resp.body).get("info") if resp.status_code == 200 else None
|
||
episodes = as_list(as_dict(as_dict(info).get("episodes")).get("episode"))
|
||
if not episodes:
|
||
raise RuntimeError("剧集列表为空或已下线")
|
||
first = next((item for item in episodes if int(as_dict(item).get("pay_type") or 0) == 0), episodes[0])
|
||
return str(as_dict(first).get("sound_id"))
|
||
|
||
|
||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||
del retry_num # 契约参数,无重试语义
|
||
songmid = str(song_info.get("songmid") or "")
|
||
sound_id = songmid
|
||
if songmid.startswith("d:"):
|
||
if not re.fullmatch(r"\d+", songmid[2:]):
|
||
raise RuntimeError("剧 id 无效")
|
||
sound_id = await _resolve_drama_first_free(songmid[2:])
|
||
elif not re.fullmatch(r"\d+", sound_id):
|
||
raise RuntimeError("me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)")
|
||
info = await get_sound_info(sound_id)
|
||
return pick_url(info, quality)
|