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:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,5 @@
|
||||
"""取流接口层(对应原 sdk/apis/*.ts)。
|
||||
|
||||
每个模块导出 `SUPPORT_QUALITYS: list[str]` 与 `async def get_music_url(song_info, quality, retry_num=0)`,
|
||||
返回 `{"type": <实际命中音质>, "url": <播放地址>}`。降级链由各平台自行实现。
|
||||
"""
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]}
|
||||
@@ -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"]}
|
||||
@@ -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("&", "&")
|
||||
|
||||
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}
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""懒人听书 URL 获取(对应 sdk/apis/lr.ts,web 通道 getPlayPath,需登录)。
|
||||
|
||||
接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1§ions=[<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§ions=[{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)}
|
||||
@@ -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)
|
||||
@@ -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 未返回可访问的音频档位")
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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"]}
|
||||
@@ -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"]}
|
||||
Reference in new issue
Block a user