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 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
"""取流接口层(对应原 sdk/apis/*.ts)。
每个模块导出 `SUPPORT_QUALITYS: list[str]` 与 `async def get_music_url(song_info, quality, retry_num=0)`,
返回 `{"type": <实际命中音质>, "url": <播放地址>}`。降级链由各平台自行实现。
"""
+78
View File
@@ -0,0 +1,78 @@
"""Apple Music 取流(对应 sdk/apis/apple.ts,上游 apple.go GetDownloadURL)。
⚠️ Apple 只提供 **preview 试听片段**(audio-ssl.itunes.apple.com,AAC/m4a,约 30-90 秒),
完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密,
本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,不假装是无损完整曲。
歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用)。
统一返回:``{type:'preview', url, ext:'m4a', preview:true, songDuration, isrc}``
(songDuration 是歌曲完整时长,试听片段本身约 30-90 秒);
type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐
/sources 与 /url?quality 枚举,返回的实际档位以 type='preview' 为准。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
SUPPORT_QUALITYS: list[str] = ["128k"]
def _is_audio_url(value: Any) -> bool:
text = str(value if value is not None else "")
return text.startswith(("http://", "https://"))
def preview_from_attributes(attr: dict[str, Any]) -> dict[str, Any]:
"""详情 → preview 结果(内部/测试用)。"""
previews = as_list(attr.get("previews"))
url = str(as_dict(previews[0]).get("url") or "") if previews else ""
if not url:
raise RuntimeError("apple music: no preview URL available (完整曲目受 DRM 保护,SDK 仅提供 30-90 秒试听片段)")
try:
duration_ms = float(attr.get("durationInMillis") or 0)
except (TypeError, ValueError):
duration_ms = 0.0
return {
"type": "preview",
"url": url,
"ext": "m4a",
"preview": True,
# 注意:这是歌曲完整时长(catalog durationInMillis),不是试听片段长度
"songDuration": math.floor(duration_ms / 1000 + 0.5),
"isrc": attr.get("isrc") or "",
}
async def get_preview_url(song_id: str) -> dict[str, Any]:
"""按 song id 拉取 preview(上游 GetDownloadURL → Parse)。"""
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
if not data:
raise RuntimeError(f"apple song not found: {song_id}")
return preview_from_attributes(as_dict(as_dict(data[0]).get("attributes")))
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流:preview 直链优先复用,否则按 song id 拉取详情。"""
del quality, retry_num # 只有 preview 一档,请求档位不影响结果
# 上游 GetDownloadURL:Song.URL 已有直链时直接复用(搜索结果里的 previewUrl / 链接解析结果)
if _is_audio_url(song_info.get("previewUrl")):
return {"type": "preview", "url": str(song_info["previewUrl"]), "ext": "m4a", "preview": True}
if _is_audio_url(song_info.get("url")):
return {"type": "preview", "url": str(song_info["url"]), "ext": "m4a", "preview": True}
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效(需要数字 song id 或 Apple Music 歌曲链接)")
return await get_preview_url(song_id)
+84
View File
@@ -0,0 +1,84 @@
"""千千音乐 取流(对应 sdk/apis/bd.ts)。
通道:`/v1/song/tracklink`(签名 GET)。档位:3000=flac / 320 / 128 / 64(aac)。
实测(2026-10 匿名):
* 免费曲目(搜索 isVip=0)各档位均返回明文直链 `data.path`(audio0x.dmhmusic.com,CDN 签名 URL);
* VIP/版权曲目返回 200 / errno 22000 但 `data` 无 path、无 trail_audio_info,
需登录态会员 cookie 才可能有直链 —— 这类曲目沿降级链走到底后如实报错,不造假数据。
**签名串与实际 query 必须同源**:`sign_params` 一次产出签名与参数,query 由
`URLSearchParams` 等价编码拼好后整串交给 `http_fetch`,不会再被二次序列化。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bd.util import api_ok, error_text, first_non_empty, num_or_zero, track_link
# 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
QUALITY_RATES: dict[str, list[str]] = {
"64k": ["64"],
"128k": ["128", "64"],
"320k": ["320", "128", "64"],
"flac": ["3000", "320", "128", "64"],
"flac24bit": ["3000", "320", "128", "64"],
}
DEFAULT_RATES = ["3000", "320", "128", "64"]
# 千千没有 flac24bit 对外档位(上游只到 3000 = flac)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac"]
def type_of_hit(request_rate: str, data: Any) -> str:
"""命中档位判定:以响应 format/rate/bits 为准(可能低于请求档位)。"""
source = data if isinstance(data, dict) else {}
fmt = str(source.get("format") or "").lower()
rate = num_or_zero(source.get("rate"))
bits = num_or_zero(source.get("bits"))
if fmt == "flac" or rate >= 1000:
return "flac24bit" if bits == 24 else "flac"
if rate >= 320 or request_rate == "320":
return "320k"
if rate >= 128 or request_rate == "128":
return "128k"
return "64k"
async def get_download_url(tsid: str, type: str | None = None) -> dict[str, Any]:
"""逐档请求 tracklink,取首个带 path 的档位。"""
id = str(tsid if tsid is not None else "").strip()
if not id:
raise RuntimeError("千千歌曲 id(TSID)为空")
rates = QUALITY_RATES.get(str(type if type is not None else ""), DEFAULT_RATES)
last_err: Exception | None = None
for rate in rates:
try:
body = await track_link(id, rate)
if not api_ok(body):
last_err = RuntimeError(error_text(body, "千千取流失败"))
continue
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
trail = data.get("trail_audio_info")
url = first_non_empty(data.get("path"), trail.get("path") if isinstance(trail, dict) else None)
if url:
return {"type": type_of_hit(rate, data), "url": url}
last_err = RuntimeError("千千该歌曲无可用直链(VIP/版权曲目需登录态,免费曲目可直接取流)")
except Exception as err: # noqa: BLE001 - 单档网络/上游失败沿降级链继续,与原实现一致
last_err = err
if last_err is not None:
raise last_err
raise RuntimeError("千千未找到可用音频直链")
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
# 上游 GetDownloadURL:Song.URL 已有直链时直接复用(Parse 场景)
if song_info.get("url"):
return {"type": quality or "128k", "url": str(song_info["url"])}
tsid = first_non_empty(song_info.get("songmid"), song_info.get("TSID"))
if not tsid:
raise RuntimeError("千千歌曲 id(TSID)为空")
return await get_download_url(tsid, quality)
+257
View File
@@ -0,0 +1,257 @@
"""Bilibili 取流(对应 sdk/apis/bili.ts,上游 bilibili/bilibili.go 的 fetchAudioURL + account.go 的 IsVipAccount)。
接口: x/player/playurl?fnval=&qn=127&bvid=&cid=
* fnval=80 标准 DASH(匿名可用)
* fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;匿名请求该参数也不报错,只是没有 flac 节点)
档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
"""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urljoin, urlsplit
from app.sdk.common.request import http_fetch, http_probe
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
bili_headers,
encode_uri_component,
fetch_view,
get_cookie,
parse_song_id,
)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
QUALITY_LABEL: dict[int, str] = {
30216: "64k",
30232: "132k",
30280: "192k",
30250: "flac",
30251: "flac24bit",
}
#: CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
PROBE_HOP_TIMEOUT = 8.0
PROBE_TOTAL_BUDGET = 20.0
PROBE_MAX_CANDIDATES = 3
_vip_cache: dict[str, Any] = {"value": False, "at": 0.0}
def reset_vip_cache() -> None:
"""登录态清空/更新后调用,丢弃大会员判断缓存。"""
_vip_cache["value"] = False
_vip_cache["at"] = 0.0
async def is_vip_account(cookie: str) -> bool:
"""上游 IsVipAccount:x/web-interface/nav 的 isLogin + vipStatus + vipType(进程内缓存 5 分钟)。"""
if not cookie:
return False
if _vip_cache["at"] and time.time() - _vip_cache["at"] < 5 * 60:
return bool(_vip_cache["value"])
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav", headers=bili_headers(cookie), timeout=15_000
)
body = resp.body if isinstance(resp.body, dict) else {}
data = as_dict(body.get("data"))
vip = (
body.get("code") == 0
and bool(data.get("isLogin"))
and int(data.get("vipStatus") or 0) == 1
and int(data.get("vipType") or 0) > 0
)
_vip_cache["value"] = vip
_vip_cache["at"] = time.time()
return vip
except Exception: # noqa: BLE001 - 判断失败按非会员处理
return False
def host_rank(url: str) -> int:
"""CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / *.akamaized.net,多地节点)
1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方)"""
try:
host = (urlsplit(str(url or "")).hostname or "").lower()
except ValueError:
return 2
if not host:
return 2
if host.endswith(".akamaized.net"):
return 0
if host.endswith(".bilivideo.com"):
return 0 if host.startswith("upos-") else 1
return 2
def node_urls(node: Any) -> list[str]:
"""节点地址收集:baseUrl + backupUrl(去重前的原始序)。"""
if not isinstance(node, dict):
return []
backups: list[Any] = []
if isinstance(node.get("backupUrl"), list):
backups.extend(node["backupUrl"])
if isinstance(node.get("backup_url"), list):
backups.extend(node["backup_url"])
urls = [node.get("baseUrl"), *backups]
return [str(url) for url in urls if url]
def rank_candidates(urls: list[str]) -> list[str]:
"""候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先)。"""
seen: set[str] = set()
uniq: list[str] = []
for url in urls:
text = str(url or "")
if not text or text in seen:
continue
seen.add(text)
uniq.append(text)
ranked = sorted(enumerate(uniq), key=lambda pair: (host_rank(pair[1]), pair[0]))
return [url for _, url in ranked]
def _pick_audio(data: Any, quality: str) -> dict[str, Any] | None:
data = as_dict(data)
dash = as_dict(data.get("dash"))
flac_info = as_dict(dash.get("flac"))
flac = as_dict(flac_info.get("audio"))
hires = flac if int(flac.get("id") or 0) == 30251 and flac.get("baseUrl") else None
dolby_list = as_list(as_dict(dash.get("dolby")).get("audio"))
dolby = None
for item in dolby_list:
if isinstance(item, dict) and int(item.get("id") or 0) == 30250 and item.get("baseUrl"):
dolby = item
break
audio_list = as_list(dash.get("audio"))
standards = sorted(
[item for item in audio_list if isinstance(item, dict) and item.get("baseUrl")],
key=lambda item: -int(item.get("id") or 0),
)
node: dict[str, Any] | None = None
label = ""
if quality == "64k":
low = next((item for item in standards if int(item.get("id") or 0) == 30216), None)
if low:
node = low
label = QUALITY_LABEL[30216]
# 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if node is None and hires:
node = hires
label = QUALITY_LABEL[30251]
if node is None and dolby:
node = dolby
label = QUALITY_LABEL[30250]
if node is None and standards:
node = standards[0]
label = QUALITY_LABEL.get(int(node.get("id") or 0), f"audio-{int(node.get('id') or 0)}")
if node is not None:
return {"id": int(node.get("id") or 0), "label": label, "urls": rank_candidates(node_urls(node))}
durl = as_list(data.get("durl"))
durl_node = durl[0] if durl and isinstance(durl[0], dict) else None
if durl_node and durl_node.get("url"):
return {"id": 0, "label": "durl", "urls": rank_candidates(node_urls(durl_node))}
return None
async def get_audio_url(bvid: str, cid: str | int, quality: str = "128k") -> dict[str, Any]:
"""播放地址 API(上游 fetchAudioURL 的等价实现,返回 ``{"type", "urls"}``;urls 已分级排序,[0] 为最优)。"""
cookie = await get_cookie()
need_flac = quality in ("flac", "flac24bit")
vip = await is_vip_account(cookie)
fnval = 4048 if (need_flac or vip) else 80
url = (
f"https://api.bilibili.com/x/player/playurl?fnval={fnval}&qn=127"
f"&bvid={encode_uri_component(bvid)}&cid={encode_uri_component(cid)}"
)
resp = await http_fetch(url, headers=bili_headers(cookie), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站取流失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站取流失败({body.get('code')}{f': {message}' if message else ''})")
picked = _pick_audio(body.get("data"), quality)
if not picked or not picked["urls"]:
raise RuntimeError("B站未返回可用音频流")
return {"type": picked["label"], "urls": picked["urls"]}
async def _resolve_url(url: str, deadline: float) -> str:
"""探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开。
HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。命中 2xx 返回该地址;
4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
"""
current = str(url)
for _hop in range(3):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("CDN 探测超时(总预算耗尽)")
timeout_ms = int(min(PROBE_HOP_TIMEOUT, remaining) * 1000)
status, location = await http_probe(
current, headers={**bili_headers(), "Range": "bytes=0-0"}, timeout_ms=timeout_ms
)
if 300 <= status < 400 and location:
try:
current = urljoin(current, location)
continue
except ValueError:
return current
if 200 <= status < 300:
return current
if status >= 400:
raise RuntimeError(f"CDN 返回 HTTP {status}")
return current
return current
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流接口:songmid 约定 ``<bvid>|<cid>``;仅给 bvid 时回源 view 取首P cid。
CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
"""
del retry_num # 契约参数,B 站按候选链自行兜底
ids = parse_song_id(song_info.get("songmid") or song_info.get("id"))
bvid = ids["bvid"]
if not bvid:
raise RuntimeError("bili songmid 需为 <bvid>|<cid> 格式")
real_cid = ids["cid"]
if not real_cid:
view = await fetch_view(bvid)
pages = as_list(view.get("pages"))
first = pages[0] if pages and isinstance(pages[0], dict) else {}
real_cid = str(first.get("cid") if first.get("cid") is not None else "")
if not real_cid:
raise RuntimeError("B站未找到视频 cid(可能已失效)")
info = await get_audio_url(bvid, real_cid, quality)
deadline = time.monotonic() + PROBE_TOTAL_BUDGET
for candidate in info["urls"][:PROBE_MAX_CANDIDATES]:
try:
final_url = await _resolve_url(candidate, deadline)
return {"type": info["type"], "url": final_url}
except Exception: # noqa: BLE001 - 换下一候选
if time.monotonic() >= deadline:
break
# 探测全败(或预算耗尽):回退分级排序后的最优候选
return {"type": info["type"], "url": info["urls"][0]}
+43
View File
@@ -0,0 +1,43 @@
"""5sing 取流(对应 sdk/apis/5sing.ts,上游 fivesing/download.go 的 GetDownloadURL)。
链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=<id>&songtype=<yc|fc|bz>
→ { code: 1000, data: { squrl/squrl_backup, hqurl/hqurl_backup, lqurl/lqurl_backup, ... } }
直链为 CDN 明文地址(wsaudiobssdlbig.kugou.com,https,无需 Referer/防盗链签名;实测 Range 206 可播)。
档位:上游固定 sq(最高)→ hq → lq,返回首个非空(含 _backup);只有显式请求 64k(或 lq)时
才倒序优先 lq,返回实际命中档位。songmid 约定 ``<songId>|<songType>``;
Parse 出来的歌曲带 url 时直接复用(上游 GetDownloadURL: if s.URL != "" return)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_audio_link
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
#: 上游只有 sq/hq/lq 三档,实际命中档位以返回 type 为准(sq 常缺,多命中 hq)
SUPPORT_QUALITYS: list[str] = ["sq", "hq", "lq"]
async def get_download_url(song_info: dict[str, Any], quality: str | None = None) -> dict[str, str]:
"""上游 GetDownloadURL 的等价实现。"""
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
# 上游逻辑:Song.URL 已带直链(Parse 场景)直接复用
if song_info.get("url"):
return {"type": str(song_info.get("type") or quality or "sq"), "url": str(song_info["url"])}
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"] or not ids["songType"]:
raise RuntimeError('invalid id structure: 5sing songmid 需为 "<songId>|<songType>"(songType 为 yc/fc/bz)')
prefer_low = quality in ("64k", "lq")
return await fetch_audio_link(ids["songID"], ids["songType"], prefer_low)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流:返回 ``{type: 'sq'|'hq'|'lq', url}``(type 为实际命中档位,可能低于请求档位)。"""
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
hit = await get_download_url(song_info, quality)
return {"type": hit["type"], "url": hit["url"]}
+65
View File
@@ -0,0 +1,65 @@
"""JOOX 取流(对应 sdk/apis/joox.ts)。
链路:GET cache.api.joox.com/openjoox2/v1/track/<songid>?...&secret=<md5>
→ ``play_url_list`` 首个 http 地址(无则 ``refrain_url``)→ 带 vkey/hdnts 的 CDN 直链。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 实测),
故取流改用官网当前使用的 openjoox2 通道(签名细节见 ``platforms/joox/music_info.py``)。
地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0;登录 cookie 的
wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
实测(游客态):免费曲目拿到 128k MP3 完整流;VIP 曲目只给 preview/refrain 片段
(``node_is_preview: true``、``error_code: 9009002``),与上游行为一致。
音质档位:游客通道实测只有 128k MP3。JOOX 的 has_hq/has_hifi 标志不代表游客可取,
登录态能否拿到更高档位未验证,故 ``SUPPORT_QUALITYS`` 保守地只声明 128k,不虚报。
"""
from __future__ import annotations
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, joox_headers, normalize_joox_id
SUPPORT_QUALITYS: list[str] = ["128k"]
#: 本平台实际只能返回这一档;返回值里的 type 用它而不是请求的 quality(与 TS 返回 type: '128k' 一致)
_ACTUAL_QUALITY = "128k"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流:openjoox2 详情 → play_url_list / refrain_url。"""
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 无效")
url = build_track_url(song_id, full_song=True)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 取流失败:{msg or f'HTTP {res.status_code}'}")
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = body.get("play_url_list")
play_url = ""
for item in urls if isinstance(urls, list) else []:
candidate = clean(item)
if candidate.startswith("http"):
play_url = candidate
break
if not play_url:
refrain = clean(body.get("refrain_url"))
if refrain.startswith("http"):
play_url = refrain
if not play_url:
raise RuntimeError("无可用播放地址(VIP/版权限制或地区不可用)")
return {"type": _ACTUAL_QUALITY, "url": play_url}
+122
View File
@@ -0,0 +1,122 @@
"""酷狗音乐 取流(对应 sdk/apis/kg.ts)。
通道:`https://gateway.kugou.com/v5/url`(x-router: trackercdn.kugou.com,android 签名)。
登录后自动带 token/userid(见 /login/kg);匿名仅能取部分免费资源,VIP/受限资源返回 status:2。
音质映射:128k→128、320k→320、flac→flac、flac24bit→high。
成功响应播放地址在顶层 url: string[] 与 backupUrl: string[];旧 data:[{url}] 仅作兼容兜底。
album_audio_id 传 NaN 会被网关拒答,必须归零。
注意:TS 原实现在这里 `getDevice()` + `androidSign`,但那两个函数在 `sdk/auth/kg.ts`,
Python 侧 `app/sdk/auth/kg.py` 仍是 stub(不在本次写入范围),因此设备指纹与签名
改为 `app/sdk/platforms/kg/util.py` 的实现 —— 算法逐字一致,指纹同样落在
`auth.json` 的 `_kg_device`,与登录态共用。
"""
from __future__ import annotations
import hashlib
import math
import time
from typing import Any
from app.sdk.auth_store import get_kg_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.util import android_sign, encode_uri_component, get_device
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
SALT_KEY = "57ae12eb6890223e355ccfcb74edf70d"
APPID = 1005
CLIENTVER = 11430
QUALITY_MAP = {"128k": "128", "320k": "320", "flac": "flac", "flac24bit": "high"}
URL_API = "https://gateway.kugou.com/v5/url"
def _num(value: Any) -> int:
"""JS 的 Number() 语义:不可解析或非有限值一律归零(网关拒答 NaN)。"""
try:
number = float(value)
except (TypeError, ValueError):
return 0
return int(number) if math.isfinite(number) else 0
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
types = song_info.get("_types") or {}
quality_info = types.get(quality) if isinstance(types, dict) else None
hash_value = str((quality_info or {}).get("hash") or song_info.get("hash") or "").lower()
if not hash_value:
raise RuntimeError("缺少 hash")
dev = get_device()
auth = get_kg_auth()
userid = _num((auth or {}).get("userid") or 0)
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"album_id": _num(song_info.get("albumId")),
"area_code": 1,
"hash": hash_value,
"ssa_flag": "is_fromtrack",
"version": CLIENTVER,
"page_id": 151369488,
"quality": QUALITY_MAP.get(quality, "128"),
"album_audio_id": _num(song_info.get("songmid")),
"behavior": "play",
"pid": 2,
"cmd": 26,
"pidversion": 3001,
"IsFreePart": 0,
"ppage_id": "463467626,350369493,788954147",
"cdnBackup": 1,
"module": "",
}
if auth:
params["token"] = auth["token"]
params["userid"] = userid
params["key"] = hashlib.md5(f"{hash_value}{SALT_KEY}{APPID}{dev['mid']}{userid}".encode()).hexdigest()
params["signature"] = android_sign(params)
qs = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"{URL_API}?{qs}",
method="get",
headers={
"dfid": dev["dfid"],
"mid": dev["mid"],
"clienttime": str(params["clienttime"]),
"kg-rc": "1",
"kg-thash": "5d816a0",
"kg-rec": "1",
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
"x-router": "trackercdn.kugou.com",
"User-Agent": "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi",
},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
raise RuntimeError(str(body.get("error") or "获取失败(该资源需要酷狗登录)"))
candidates: list[Any] = []
if isinstance(body.get("url"), list):
candidates.extend(body["url"])
if isinstance(body.get("backupUrl"), list):
candidates.extend(body["backupUrl"])
url = next((str(item) for item in candidates if item), "")
if not url and isinstance(body.get("data"), list):
for entry in body["data"]:
if isinstance(entry, dict) and (entry.get("url") or entry.get("fallback")):
url = str(entry.get("url") or entry.get("fallback"))
break
if not url:
raise RuntimeError("获取失败(网关未返回播放地址)")
return {"type": quality, "url": url}
+220
View File
@@ -0,0 +1,220 @@
"""酷我音乐 取流(对应 sdk/apis/kw.ts)。
双通道,按优先级:
1) mobi.s convert_url_with_sign(主通道,匿名可用)
`source=kwplayer_ar_8.5.5.0_apk_keluze.apk` + `user=0` 即可匿名拿到真音频,
但请求档位缺失时会**静默换档**(实测 rid=440613 请求 4000kflac 回的是
bitrate=20501 / format=mflac 的加密流,不可播),因此必须严格校验
「返回 bitrate 精确等于请求档位」且「format 在明文白名单内」,
任一不满足视为未命中、沿降级链换档重试。
2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
签名所需 cookie(`Hm_Iuvt_*`,名字与值均随机下发)必须现场访问首页动态获取;
写死 token 一律返回 `The request is illegal!`(已实测确认)。
匿名该接口恒回 128k MP3,是 mobi 通道网络故障时的兜底。
CDN 返回的 URL query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`),
非标准分隔符播放器不认,统一还原成 `=` 再对外返回。
"""
from __future__ import annotations
import asyncio
import re
import uuid
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kw.util import (
HOME_URL,
MOBI_UA,
UA,
fetch_home_set_cookie,
random_digits,
)
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
# 音质 → mobi.s 请求档位(bitrate 是响应必须精确命中的数值)
QUALITY_TIERS: dict[str, dict[str, Any]] = {
"128k": {"br": "128kmp3", "bitrate": 128},
"320k": {"br": "320kmp3", "bitrate": 320},
"flac": {"br": "2000kflac", "bitrate": 2000},
"flac24bit": {"br": "4000kflac", "bitrate": 4000},
}
# 档位缺失时的降级链(请求档位 → 逐级尝试)
FALLBACK_CHAIN: dict[str, list[str]] = {
"flac24bit": ["flac", "320k", "128k"],
"flac": ["320k", "128k"],
"320k": ["128k"],
"128k": [],
}
# 明文音频格式白名单;mgg/mflac/mmp4/zp 是加密格式,拿到也不能播
PLAIN_FORMATS = frozenset({"mp3", "flac", "ogg", "aac", "m4a"})
MOBI_API = "http://mobi.kuwo.cn/mobi.s"
PLAY_URL_API = "https://www.kuwo.cn/api/v1/www/music/playUrl"
_TOKEN_NAME_RE = re.compile(r"^Hm_Iuvt_\w+$")
_ILLEGAL_RE = re.compile(r"illegal", re.IGNORECASE)
def normalize_url(url: str) -> str:
return url.replace("$", "=")
async def get_url_by_mobi(songmid: str, tier: dict[str, Any]) -> str | None:
"""mobi.s 取流:未命中(无 url / bitrate 不符 / 加密格式 / 网络异常)返回 None,由调用方换档。"""
url = (
f"{MOBI_API}?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk"
f"&type=convert_url_with_sign&br={tier['br']}&rid={songmid}"
)
try:
resp = await http_fetch(
url,
method="get",
headers={"User-Agent": MOBI_UA, "Accept-Language": "zh-CN,zh;q=0.9"},
timeout=15_000,
)
except Exception: # noqa: BLE001 — 网络失败视为本档未命中,继续降级
return None
body = resp.body if isinstance(resp.body, dict) else {}
data = body.get("data") or {}
if resp.status_code != 200 or not isinstance(data, dict) or not data.get("url"):
return None
raw_bitrate = data.get("bitrate")
if not isinstance(raw_bitrate, (int, float, str)):
return None
try:
bitrate = int(float(raw_bitrate))
except (TypeError, ValueError):
return None
if bitrate != tier["bitrate"] or str(data.get("format")) not in PLAIN_FORMATS:
return None
return normalize_url(str(data["url"]))
def kw_secret(message: str, key: str) -> str:
"""酷我 web 端 Secret 头哈希:key 为 cookie 名,message 为 cookie 值。
逐字移植自 web 端 secret.js:先把 key 的每个字符码拼成数字串,
取 5 个固定位置的数字组成乘子,再用随机种子一起做 LCG 式的异或掩码。
"""
digits = "".join(str(ord(ch)) for ch in key)
step = len(digits) // 5
multiplier = int(digits[step] + digits[2 * step] + digits[3 * step] + digits[4 * step] + digits[5 * step])
addend = -(-len(key) // 2) # Math.ceil(e.length / 2)
modulus = 2**31 - 1
if multiplier < 2:
raise RuntimeError("酷我 Secret 哈希失败")
seed = random_digits() % 10**8
digits += str(seed)
while len(digits) > 10:
digits = str(int(digits[:10]) + int(digits[10:]))
state = (multiplier * int(digits) + addend) % modulus
out: list[str] = []
for ch in message:
mask = int(state / modulus * 255) # 对应 Math.floor(n / c * 255)
value = ord(ch) ^ mask
out.append(f"{value:02x}" if value < 16 else format(value, "x"))
state = (multiplier * state + addend) % modulus
return "".join(out) + f"{seed:08x}"
class KwToken:
__slots__ = ("name", "secret", "value")
def __init__(self, name: str, value: str) -> None:
self.name = name
self.value = value
self.secret = kw_secret(value, name)
_state: dict[str, Any] = {"token": None, "lock": None}
def _lock() -> asyncio.Lock:
lock = _state["lock"]
if lock is None:
lock = asyncio.Lock()
_state["lock"] = lock
return lock
async def get_token(retry_num: int = 0) -> KwToken:
"""访问首页解析动态下发的 `Hm_Iuvt_*` cookie,生成 Secret。会话级缓存。"""
cached: KwToken | None = _state["token"]
if cached is not None:
return cached
async with _lock():
cached = _state["token"]
if cached is not None:
return cached
try:
cookies = await fetch_home_set_cookie()
except Exception as err:
if retry_num >= 2:
raise RuntimeError(f"获取酷我token失败: {err}") from err
return await get_token(retry_num + 1)
found: list[tuple[str, str]] = []
for raw in cookies:
name, _, rest = raw.partition("=")
name = name.strip()
value = rest.split(";")[0] if rest else ""
if _TOKEN_NAME_RE.match(name) and value:
found.append((name, value))
if not found:
if retry_num >= 2:
raise RuntimeError("获取酷我token失败")
return await get_token(retry_num + 1)
token = KwToken(found[0][0], found[0][1])
_state["token"] = token
return token
async def get_url_by_playurl(songmid: str, token: KwToken, retry_num: int = 0) -> dict[str, Any]:
"""官方 playUrl 接口(需 Secret 签名)。签名/token 失效时刷新后重试一次。"""
url = f"{PLAY_URL_API}?mid={songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId={uuid.uuid4()}"
resp = await http_fetch(
url,
method="get",
headers={
"Cookie": f"{token.name}={token.value}",
"Secret": token.secret,
"Referer": HOME_URL,
"User-Agent": UA,
},
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
msg = str(body.get("msg") or body.get("message") or "")
data = body.get("data") or {}
if resp.status_code != 200 or not body.get("success") or not isinstance(data, dict) or not data.get("url"):
if retry_num == 0 and _ILLEGAL_RE.search(msg):
_state["token"] = None
fresh = await get_token()
return await get_url_by_playurl(songmid, fresh, retry_num + 1)
raise RuntimeError(msg or "获取失败")
# 服务器无视 br 参数,匿名实际只回 128k,如实上报
return {"type": "128k", "url": normalize_url(str(data["url"]))}
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "").strip()
if not songmid:
raise RuntimeError("缺少 songmid")
start = quality if quality in QUALITY_TIERS else "128k"
# 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
for tier_type in [start, *FALLBACK_CHAIN[start]]:
hit = await get_url_by_mobi(songmid, QUALITY_TIERS[tier_type])
if hit:
return {"type": tier_type, "url": hit}
token = await get_token()
return await get_url_by_playurl(songmid, token)
+57
View File
@@ -0,0 +1,57 @@
"""懒人听书 URL 获取(对应 sdk/apis/lr.ts,web 通道 getPlayPath,需登录)。
接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1&sections=[<section>]&type=0
实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
成功响应 list[0].path 为音频直链。
songmid 为复合 id ``<bookId>:<section>``(/album/lr 章节列表产出);纯 bookId 视为第一章。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.auth.lr import get_lr_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
UA_MI = (
"Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0"
)
PLAY_URL = "https://m.lrts.me/ajax/getPlayPath"
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
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 "")
parts = songmid.split(":")
book_id = parts[0] if parts else ""
section = parts[1] if len(parts) > 1 and parts[1] else "1"
if not book_id or not re.fullmatch(r"\d+", book_id):
raise RuntimeError("懒人 id 需为 <bookId>[:<section>](见 /album/lr 章节列表)")
cookie = get_lr_cookie()
if not cookie:
raise RuntimeError("懒人听书取流需要登录(POST /login/lr 绑定账号密码)")
resp = await http_fetch(
f"{PLAY_URL}?entityId={book_id}&entityType=3&opType=1&sections=[{section}]&type=0",
headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/", "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200:
raise RuntimeError(f"取流失败(HTTP {resp.status_code})")
items = as_list(body.get("list"))
path = as_dict(items[0]).get("path") if items else None
if not path:
if body.get("status") == 2:
raise RuntimeError(str(body.get("msg") or "收费章节未购买(需账号已购/会员)"))
if "登录" in str(body.get("msg") or ""):
raise RuntimeError("登录态已失效,请重新 POST /login/lr")
raise RuntimeError(str(body.get("msg") or "取流失败"))
return {"type": "128k" if quality == "128k" else "64k", "url": str(path)}
+78
View File
@@ -0,0 +1,78 @@
"""猫耳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)
+390
View File
@@ -0,0 +1,390 @@
"""咪咕音乐 取流(对应 sdk/apis/mg.ts)。
候选链:listen-url 多档位请求 → 按档位优先级产出「内嵌加密 / 明文直链 / 档位路径重写」
三类候选 → 逐条 Range 0-63 探测(校验音频魔数,加密流先解密再校验)→ 返回实际命中的最高档位。
匿名态仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie(接口回「登录后免费试听」)。
加密流:返回 `{"encrypted": True, "fileKey": "..."}`,路由据此改写成 `/stream/mg/<票据>`,
真正的逐字节解密在 `app/http/mg_stream.py`(本模块只负责产出字段与探测阶段的自校验)。
上游对应 music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import quote, unquote, urlencode, urlsplit, urlunsplit
from app.sdk.common.request import http_fetch
from app.sdk.platforms.mg.util import (
ANDROID_CHANNEL,
ANDROID_UA,
ANDROID_VERSION,
TONE_EXT,
TONE_QUALITY,
decrypt_z3d,
first_non_empty,
get_migu_cookie,
is_migu_audio_magic,
migu_format_ext,
normalize_tone,
parse_int,
)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
LISTEN_URL = "https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url"
#: 平台档位 → 咪咕 toneFlag(取流请求用)
QUALITY_TONE: dict[str, str] = {
"64k": "LQ",
"128k": "PQ",
"320k": "HQ",
"flac": "SQ",
"flac24bit": "ZQ24",
"wav": "ZQ32",
}
#: 可重写的 tone 路径(上游 miguTonePaths)
TONE_PATHS: dict[str, str] = {
"LQ": "全曲试听/Mp3_64_22_16",
"PQ": "标清高清/MP3_128_16_Stero",
"HQ": "标清高清/MP3_320_16_Stero",
"SQ": "歌曲下载/flac",
"ZQ": "歌曲下载/flac_24bit",
"ZQ24": "歌曲下载/flac_24bit",
"ZQ32": "歌曲下载/wav_32bit",
}
TONE_PREFERENCE: list[str] = ["ZQ32", "ZQ24", "ZQ", "SQ", "HQ", "Z3D", "I3D", "3D60", "PQ", "LQ"]
#: 档位路径探测顺序(长的在前,避免 `/flac` 抢匹配 `/flac_24bit`)
_TONE_PATH_ORDER = ("ZQ24", "ZQ", "ZQ32", "SQ", "HQ", "LQ", "PQ")
MAX_PROBES = 10
def migu_song_parts(song_info: dict[str, Any]) -> dict[str, str]:
"""songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c`。"""
extra = song_info.get("extra") or {}
extra = extra if isinstance(extra, dict) else {}
content_id = str(extra.get("content_id") or "").strip()
resource_type = str(extra.get("resource_type") or "").strip()
format_type = str(extra.get("format_type") or "").strip()
if content_id and resource_type and format_type:
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
raw_id = str(song_info.get("songmid") or song_info.get("id") or "")
parts = raw_id.split("|")
if len(parts) == 3:
return {"contentId": parts[0].strip(), "resourceType": parts[1].strip(), "formatType": parts[2].strip()}
if not content_id:
content_id = raw_id.strip()
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
async def _fetch_listen_info(
content_id: str, copyright_id: str, song_id: str, album_id: str, resource_type: str, tone_flag: str
) -> dict[str, Any]:
"""listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误。"""
params: list[tuple[str, str]] = [
("netType", "01"),
("resourceType", first_non_empty(resource_type, "2")),
("contentId", content_id.strip()),
("toneFlag", first_non_empty(tone_flag, "PQ")),
]
if copyright_id.strip():
params.append(("copyrightId", copyright_id.strip()))
if song_id.strip():
params.append(("songId", song_id.strip()))
if album_id.strip():
params.append(("albumId", album_id.strip()))
headers: dict[str, str] = {
"User-Agent": ANDROID_UA,
"channel": ANDROID_CHANNEL,
"version": ANDROID_VERSION,
"Referer": "https://music.migu.cn/",
}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(f"{LISTEN_URL}?{urlencode(params)}", headers=headers, timeout=30_000)
if resp.status_code < 200 or resp.status_code >= 300:
raise RuntimeError(f"migu listen endpoint returned status {resp.status_code}")
result = resp.body if isinstance(resp.body, dict) else {}
if result.get("code") and result["code"] != "000000":
raise RuntimeError(f"migu api error: {result.get('info')} (code {result.get('code')})")
return result
def _normalize_download_url(raw_url: Any) -> str:
url = str(raw_url if raw_url is not None else "").strip()
if not url:
return ""
if url.startswith("//"):
url = f"https:{url}"
prefix = "ftp://218.200.160.122:21/"
if url.startswith(prefix):
url = f"https://freetyst.nf.migu.cn/{url[len(prefix) :]}"
return url
def _direct_download_info(raw_url: str, fmt: str, expected_size: int) -> dict[str, Any] | None:
url = _normalize_download_url(raw_url)
if not url:
return None
ext = ""
try:
path = urlsplit(url).path
ext = (path.split(".")[-1] or "").lower()
if not ext or "/" in ext:
ext = ""
except ValueError:
ext = ""
tone = normalize_tone(fmt)
if not ext:
ext = TONE_EXT.get(tone, "")
if not ext:
ext = "mp3"
if not tone:
# 按扩展名回填档位(上游 formatFromMiguExt)
tone = {"flac": "SQ", "wav": "ZQ32", "m4a": "I3D"}.get(ext, "PQ")
return {"url": url, "format": tone, "ext": ext, "encrypted": False, "fileKey": "", "size": expected_size}
def _embedded_download_info(resp: dict[str, Any], target_tone: str, expected_size: int) -> dict[str, Any] | None:
"""z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse。"""
data = resp.get("data") if isinstance(resp, dict) else None
song_item = (data or {}).get("songItem") if isinstance(data, dict) else None
code = (song_item or {}).get("z3dCode") if isinstance(song_item, dict) else None
if not isinstance(code, dict):
return None
def parse_size(value: Any) -> int:
size = parse_int(value)
return size if size > 0 else 0
if target_tone == "Z3D":
url = _normalize_download_url(first_non_empty(code.get("androidUrl"), code.get("url")))
file_key = str(code.get("androidFileKey") or "").strip()
if not url or not file_key:
return None
ext = first_non_empty(str(code.get("androidFileType") or "").removeprefix("."), "wav")
return {
"url": url,
"format": "Z3D",
"ext": ext,
"encrypted": True,
"fileKey": file_key,
"size": parse_size(code.get("androidSize")) or expected_size,
}
if target_tone == "I3D":
url = _normalize_download_url(first_non_empty(code.get("iosUrl"), code.get("url")))
file_key = str(code.get("iosFileKey") or "").strip()
if not url or not file_key:
return None
ext = first_non_empty(str(code.get("iosFileType") or "").removeprefix("."), "m4a")
return {
"url": url,
"format": "I3D",
"ext": ext,
"encrypted": True,
"fileKey": file_key,
"size": parse_size(code.get("iosSize")) or expected_size,
}
if target_tone == "3D60":
url = _normalize_download_url(first_non_empty(code.get("url"), code.get("h5Url")))
if not url:
return None
ext = first_non_empty(str(code.get("fileType") or "").removeprefix("."), "wav")
return {
"url": url,
"format": "3D60",
"ext": ext,
"encrypted": False,
"fileKey": "",
"size": parse_size(code.get("size")) or expected_size,
}
return None
def _detect_tone_path(decoded_path: str) -> str:
for tone in _TONE_PATH_ORDER:
tone_path = TONE_PATHS.get(tone, "")
if tone_path and tone_path in decoded_path:
return tone_path
return ""
def _rewrite_tone_url(raw_url: str, target_tone: str) -> str | None:
"""把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL)。"""
tone = normalize_tone(target_tone)
target_path = TONE_PATHS.get(tone)
target_ext = TONE_EXT.get(tone)
if not target_path or not target_ext or tone in ("Z3D", "I3D", "3D60"):
return None
try:
parts = urlsplit(_normalize_download_url(raw_url))
except ValueError:
return None
decoded_path = unquote(parts.path)
source_path = _detect_tone_path(decoded_path)
if not source_path:
return None
new_path = decoded_path.replace(source_path, target_path)
if new_path == decoded_path:
return None
dot_index = new_path.rfind(".")
if dot_index > new_path.rfind("/"):
new_path = new_path[:dot_index]
new_path += f".{target_ext}"
# JS 的 `parsed.pathname = ...` 会把中文等非 ASCII 重新百分号编码
return urlunsplit((parts.scheme, parts.netloc, quote(new_path, safe="/"), parts.query, parts.fragment))
def _build_candidates(responses: list[dict[str, Any]], expected_size: int) -> list[dict[str, Any]]:
"""候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选。"""
sources: list[dict[str, str]] = []
seen_sources: set[str] = set()
for resp in responses:
data = resp.get("data") if isinstance(resp, dict) else None
candidate_url = _normalize_download_url((data or {}).get("url") if isinstance(data, dict) else None)
if not candidate_url:
continue
actual_format = normalize_tone((data or {}).get("formatType")) or "PQ"
key = f"{actual_format}\u0000{candidate_url}"
if key in seen_sources:
continue
seen_sources.add(key)
sources.append({"url": candidate_url, "format": actual_format})
candidates: list[dict[str, Any]] = []
seen: set[str] = set()
def add(info: dict[str, Any] | None) -> None:
if not info or not info["url"]:
return
key = f"{info['format']}\u0000{info['url']}"
if key in seen:
return
seen.add(key)
candidates.append(info)
for tone in TONE_PREFERENCE:
for resp in responses:
add(_embedded_download_info(resp, tone, expected_size))
for source in sources:
if tone == source["format"]:
add(_direct_download_info(source["url"], source["format"], expected_size))
continue
rewritten = _rewrite_tone_url(source["url"], tone)
if rewritten:
add(_direct_download_info(rewritten, tone, 0))
return candidates
async def _download_info_valid(info: dict[str, Any]) -> bool:
"""探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积。"""
try:
headers: dict[str, str] = {
"User-Agent": ANDROID_UA,
"Referer": "https://music.migu.cn/",
"Range": "bytes=0-63",
}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(info["url"], headers=headers, timeout=10_000)
if resp.status_code < 200 or resp.status_code >= 300:
return False
prefix = resp.raw[:64]
if not prefix:
return False
if info["encrypted"]:
try:
decrypt_z3d(prefix, info["fileKey"])
except Exception: # noqa: BLE001 - 探测失败即视为该候选不可用
return False
elif not is_migu_audio_magic(prefix):
return False
content_range = resp.header("content-range")
slash = content_range.rfind("/")
if slash >= 0:
size = parse_int(content_range[slash + 1 :])
if size > 0:
info["size"] = size
return True
except Exception: # noqa: BLE001 - 任一候选探测异常都只代表该候选不可用
return False
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口:候选链 + 逐档探测;返回实际命中的最高档位。"""
parts = migu_song_parts(song_info)
content_id = parts["contentId"]
if not content_id:
raise RuntimeError("migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)")
resource_type = parts["resourceType"] or "2"
target_tone = (
normalize_tone(QUALITY_TONE.get(quality, ""))
or normalize_tone(parts["formatType"])
or normalize_tone(song_info.get("ext"))
or "PQ"
)
extra = song_info.get("extra") or {}
extra = extra if isinstance(extra, dict) else {}
copyright_id = str(extra.get("copyright_id") or "")
song_id = str(extra.get("song_id") or "")
album_id = str(extra.get("album_id") or song_info.get("albumId") or "")
request_tones: list[str] = []
for tone in ("ZQ32", target_tone, "PQ"):
if tone not in request_tones:
request_tones.append(tone)
responses: list[dict[str, Any]] = []
fetch_err: Exception | None = None
for tone in request_tones:
try:
responses.append(await _fetch_listen_info(content_id, copyright_id, song_id, album_id, resource_type, tone))
except Exception as err: # noqa: BLE001 - 多档位请求,单档失败不影响其它档
if fetch_err is None:
fetch_err = err
if not responses:
raise fetch_err or RuntimeError("migu listen-url 全部失败")
expected_size = parse_int(song_info.get("size"))
candidates = _build_candidates(responses, expected_size)
for probed, candidate in enumerate(candidates):
if probed >= MAX_PROBES:
break
if await _download_info_valid(candidate):
result: dict[str, Any] = {
"type": TONE_QUALITY.get(candidate["format"], quality),
"url": candidate["url"],
}
if candidate["encrypted"]:
result["encrypted"] = True
result["fileKey"] = candidate["fileKey"]
result["ext"] = candidate["ext"] or migu_format_ext(candidate["format"])
result["size"] = candidate["size"]
return result
# 全部候选不可用:优先回传接口文案(如「登录后免费试听」)
dialog = next(
(
str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
for resp in responses
if str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
),
"",
)
if dialog:
raise RuntimeError(f"migu 无可用音频:{dialog}(登录后可解锁更高档位)")
if fetch_err is not None:
raise fetch_err
raise RuntimeError("migu 未返回可访问的音频档位")
+42
View File
@@ -0,0 +1,42 @@
"""蜻蜓FM URL 获取(对应 sdk/apis/qt.ts,点播 audiostream 签名直链)。
链路(官网 main.js 逆向,2026-10-02 实测):
GET https://audio.qtfm.cn/audiostream/redirect/<channel>/<program>?access_token=&device_id=MOBILESITE&qingting_id=&t=<ms>&sign=<hex>
sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外)
匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3)
直播电台另有 /live/<cid>/64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn)
songmid 为复合 id ``<channelId>:<programId>``(/album/qt 列表产出)。
"""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Any
SIGN_KEY = b"7l8CZ)SgZgM_bkrw"
AUDIO_HOST = "https://audio.qtfm.cn"
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def build_program_url(channel_id: str, program_id: str) -> str:
path = (
f"/audiostream/redirect/{channel_id}/{program_id}"
f"?access_token=&device_id=MOBILESITE&qingting_id=&t={int(time.time() * 1000)}"
)
sign = hmac.new(SIGN_KEY, path.encode(), hashlib.md5).hexdigest()
return f"{AUDIO_HOST}{path}&sign={sign}"
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 "")
parts = songmid.split(":")
channel_id = parts[0] if parts else ""
program_id = parts[1] if len(parts) > 1 else ""
if not channel_id or not program_id:
raise RuntimeError("蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)")
return {"type": "128k" if quality == "128k" else "64k", "url": build_program_url(channel_id, program_id)}
+145
View File
@@ -0,0 +1,145 @@
"""QQ音乐取流(移植自 sdk/apis/tx.ts)。
接口:musicu.fcg 的 vkey.GetVkeyServer/CgiGetVkey。
取流方案(与原文一致):
* 一次请求带上整条音质候选链的 filename,命中第一个有 purl 的档位即「最高可用音质」;
* 始终附带 `C400{songmid}.m4a` 保底 —— 匿名账户唯一能拿到的 128k AAC;
* 档位前缀:RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC > F000 无损 FLAC >
M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
* VIP 档 filename 用 media_mid(songmid ≠ media_mid 时用媒体 id),保底档用 songmid;
* 登录 cookie(uin + qm_keyst,见 /login/tx)才可能解锁 320k/无损/Hi-Res,
未登录时高档位 purl 为空,只能落到 C400 或 testfile2g 试听地址。
参数映射:路由的 song_info 只有 {"songmid", "albumId", ...},没有 media_mid。
因此先查 songmid → media_mid(musicu 的 pf_song_detail_svr),查不到就退回用 songmid
(与原文 `songInfo.strMediaMid || songInfo.songmid` 同义)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils import MUSICU_URL
# 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
_CANDIDATES: dict[str, list[tuple[str, str, str]]] = {
"128k": [("M500", "mp3", "128k")],
"320k": [("M800", "mp3", "320k"), ("M500", "mp3", "128k")],
"flac": [("F000", "flac", "flac"), ("M800", "mp3", "320k"), ("M500", "mp3", "128k")],
"flac24bit": [
("RS01", "flac", "flac24bit"),
("Q000", "flac", "flac24bit"),
("AI00", "flac", "flac24bit"),
("F000", "flac", "flac"),
("M800", "mp3", "320k"),
("M500", "mp3", "128k"),
],
}
SUPPORT_QUALITYS: list[str] = list(_CANDIDATES)
_UA = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
_mid_cache: dict[str, str] = {}
async def _resolve_media_mid(songmid: str) -> str:
"""songmid → media_mid。失败/缺失时返回空串,由调用方退回 songmid。"""
cached = _mid_cache.get(songmid)
if cached is not None:
return cached
media_mid = ""
try:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"module": "music.pf_song_detail_svr",
"method": "get_song_detail_yqq",
"param": {"song_type": 0, "song_mid": songmid},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
track = ((body.get("req") or {}).get("data") or {}).get("track_info") or {}
media_mid = str((track.get("file") or {}).get("media_mid") or "")
except (RuntimeError, OSError, TimeoutError):
# 查不到不阻塞取流:退回 songmid(无名曲目/接口抖动时的降级行为)
return ""
_mid_cache[songmid] = media_mid
return media_mid
def _build_entries(song_info: dict[str, Any], quality: str, media_mid: str) -> list[dict[str, str]]:
songmid = str(song_info.get("songmid") or "")
entries: list[dict[str, str]] = []
for prefix, ext, hit_type in _CANDIDATES.get(quality) or _CANDIDATES["128k"]:
entries.append({"filename": f"{prefix}{media_mid}.{ext}", "type": hit_type})
# 保底:匿名 128k AAC 用 songmid(不是 media_mid)
entries.append({"filename": f"C400{songmid}.m4a", "type": "128k"})
return entries
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。返回 {"type": 实际命中音质, "url": 播放地址}(type 可能低于请求值)。
失败文案与原文一致:`获取失败`。
"""
if retry_num > 2:
raise RuntimeError("获取失败")
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取失败")
media_mid = str(song_info.get("strMediaMid") or "") or await _resolve_media_mid(songmid) or songmid
entries = _build_entries(song_info, quality, media_mid)
auth = get_tx_auth()
uin = str(auth.get("uin") or "0") if auth else "0"
headers = {"User-Agent": _UA, "Referer": "https://y.qq.com/"}
if auth and auth.get("cookie"):
headers["cookie"] = str(auth["cookie"])
data = {
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {
"module": "vkey.GetVkeyServer",
"method": "CgiGetVkey",
"param": {
"guid": "7342827269",
"songmid": [songmid],
"filename": [entry["filename"] for entry in entries],
"songtype": [0],
"uin": uin,
"loginflag": 1,
"platform": "20",
"needNewCode": 1,
},
},
}
resp = await http_fetch(MUSICU_URL, method="post", headers=headers, body=data, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
return await get_music_url(song_info, quality, retry_num + 1)
payload = req.get("data") or {}
sip = (payload.get("sip") or ["http://aqqmusic.tc.qq.com/"])[0]
infos = payload.get("midurlinfo") or []
for index, info in enumerate(infos):
if info.get("purl"):
hit_type = entries[index]["type"] if index < len(entries) else "128k"
return {"type": hit_type, "url": f"{sip}{info['purl']}"}
# 全部受限(VIP 歌匿名)时回退服务器给的 128k m4a 试听文件
if payload.get("testfile2g"):
return {"type": "128k", "url": f"{sip}{payload['testfile2g']}"}
return await get_music_url(song_info, quality, retry_num + 1)
+56
View File
@@ -0,0 +1,56 @@
"""网易云音乐 取流接口(移植自 sdk/apis/wy.ts)。
接口: https://interface3.music.163.com/eapi/song/enhance/player/url/v1(eapi 加密请求,响应为明文 JSON)
音质: 128k → standard, 320k → exhigh, flac → lossless, flac24bit → hires
未登录时不带 cookie 走匿名通道(音质上限由服务端决定),与原实现一致。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_wy_cookie
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.utils import UA_WIN_CHROME
from app.sdk.platforms.wy.utils.crypto import eapi
LEVEL_MAP = {
"128k": "standard",
"320k": "exhigh",
"flac": "lossless",
"flac24bit": "hires",
}
# 官方直连接口支持的音质(sdk/__init__.py 汇总为 support_quality)
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
URL_HOST = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。失败抛 RuntimeError(文案与原文一致:获取失败)。"""
data = {
"ids": f"[{song_info.get('songmid')}]",
"level": LEVEL_MAP.get(quality, "standard"),
"encodeType": "flac",
}
headers = {"User-Agent": UA_WIN_CHROME, "origin": "https://music.163.com"}
cookie = get_wy_cookie()
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
URL_HOST,
method="post",
headers=headers,
form=eapi("/api/song/enhance/player/url/v1", data),
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取失败")
items = body.get("data") or []
item = items[0] if items else None
if not isinstance(item, dict) or not item.get("url"):
raise RuntimeError("获取失败")
return {"type": quality, "url": item["url"]}
+63
View File
@@ -0,0 +1,63 @@
"""喜马拉雅 URL 获取(对应 sdk/apis/xm.ts,PC 桌面端 baseInfo 通道)。
链路:make_xm_sign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList
→ decode_win_url(AES-ECB,客户端 4.0.14 asar 密钥) → a.xmcdn.com 带签名直链。
实测(2026-10-02)匿名免费曲目 HTTP 200 audio/mp4 可播;付费/VIP 曲 ret=1001(需登录)。
档位命名如 M4A_64/MP3_64/M4A_24/MP3_32/AAC_24(音乐内容另有 MP3_128/M4A_128);
喜马拉雅以有声内容为主,码率低于音乐平台,音质映射到 '64k'/'128k' 桶。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.xm.decoder import decode_win_url
from app.sdk.platforms.xm.music_info import get_track_info, play_url_list
#: 优先 M4A(同码率体积更小),128 档优先 MP3_128
QUALITY_PREFERENCE: dict[str, list[str]] = {
"128k": ["MP3_128", "M4A_128", "M4A_64", "MP3_64", "M4A_24", "MP3_32", "AAC_24"],
"64k": ["M4A_64", "MP3_64", "M4A_24", "MP3_32", "AAC_24"],
}
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def _bitrate_of(type_name: str) -> int:
match = re.search(r"(\d+)", str(type_name or ""))
return int(match.group(1)) if match else 0
def _bucket_of(bitrate: int) -> str:
return "128k" if bitrate >= 128 else "64k"
def _resolve_url(raw: str) -> str:
return raw if raw.startswith("http") else decode_win_url(raw)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流:按档位偏好找 playUrlList,未知命名时取码率最高的一条。"""
del retry_num # 契约参数,无重试语义
track_id = str(song_info.get("songmid") or "")
track_info = await get_track_info(track_id)
play_list = play_url_list(track_info)
if not play_list:
raise RuntimeError("无可用播放资源(付费/版权内容需登录)")
preference = QUALITY_PREFERENCE.get(quality, QUALITY_PREFERENCE["128k"])
picked: dict[str, str] | None = None
for want in preference:
hit = next((item for item in play_list if item.get("type") == want and item.get("url")), None)
if hit:
picked = {"type": str(hit["type"]), "url": _resolve_url(str(hit["url"]))}
break
if picked is None:
# 兜底:未知档位命名时取码率最高的一条
candidates = [item for item in play_list if item.get("url")]
if not candidates:
raise RuntimeError("无可用播放资源")
best = sorted(candidates, key=lambda item: _bitrate_of(str(item.get("type") or "")), reverse=True)[0]
picked = {"type": str(best["type"]), "url": _resolve_url(str(best["url"]))}
return {"type": _bucket_of(_bitrate_of(picked["type"])), "url": picked["url"]}