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

+267
View File
@@ -0,0 +1,267 @@
"""SDK 聚合入口 —— 平台注册表、跨平台搜索与精确匹配。
对应原 sdk/index.ts。平台 id 对外为短码(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple),
sdk 目录与导入名使用平台名(bd → qian 目录历史已合并,见下)。
各平台包导出 `MODULE`(PlatformModule 实例),能力位按 snake_case 命名:
music_search / leaderboard / song_list / hot_search / comment / singer /
album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.utils import decode_name
# 通用工具函数(平台模块 `from app.sdk import format_play_time` 的解析目标)
__all__ = [
"SOURCE_MODULES",
"SUPPORTED_SOURCES",
"date_format",
"date_format2",
"decode_name",
"find_music",
"format_play_count",
"format_play_time",
"init",
"search_music",
"size_formate",
"sources",
"support_quality",
]
from app.sdk.common.utils import (
date_format,
date_format2,
format_play_count,
format_play_time,
size_formate,
)
_SINGERS_SEPARATOR = re.compile(r"、|&|;|;|/|,|,|\||\]|\[|!|!")
def _load_platforms() -> list[dict[str, Any]]:
"""按显示顺序加载平台包。导入失败即启动失败(与原 TS 静态导入一致)。"""
from app.sdk.platforms import apple, bd, bili, fivesing, joox, kg, kw, lr, me, mg, qt, tx, wy, xm
# no_aggregate:有声/播客类平台不参与聚合搜索与跨平台匹配,仅支持 source 直查
return [
{"id": "kw", "name": "酷我音乐", "module": kw.MODULE},
{"id": "kg", "name": "酷狗音乐", "module": kg.MODULE},
{"id": "tx", "name": "QQ音乐", "module": tx.MODULE},
{"id": "wy", "name": "网易音乐", "module": wy.MODULE},
{"id": "xm", "name": "喜马拉雅", "module": xm.MODULE, "no_aggregate": True},
{"id": "me", "name": "猫耳FM", "module": me.MODULE, "no_aggregate": True},
{"id": "qt", "name": "蜻蜓FM", "module": qt.MODULE, "no_aggregate": True},
{"id": "lr", "name": "懒人听书", "module": lr.MODULE, "no_aggregate": True},
{"id": "mg", "name": "咪咕音乐", "module": mg.MODULE},
{"id": "bd", "name": "千千音乐", "module": bd.MODULE},
{"id": "joox", "name": "JOOX", "module": joox.MODULE},
{"id": "bili", "name": "哔哩哔哩", "module": bili.MODULE},
{"id": "5sing", "name": "5sing", "module": fivesing.MODULE},
# Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
{"id": "apple", "name": "Apple Music", "module": apple.MODULE, "no_aggregate": True},
]
SUPPORTED_SOURCES: list[dict[str, Any]] = _load_platforms()
SOURCE_MODULES: dict[str, Any] = {item["id"]: item["module"] for item in SUPPORTED_SOURCES}
sources: list[dict[str, Any]] = [
{"id": item["id"], "name": item["name"], **({"noAggregate": True} if item.get("no_aggregate") else {})}
for item in SUPPORTED_SOURCES
]
def _quality_table() -> dict[str, list[str]]:
from app.sdk.apis import apple as apple_api
from app.sdk.apis import bd as bd_api
from app.sdk.apis import bili as bili_api
from app.sdk.apis import fivesing as fivesing_api
from app.sdk.apis import joox as joox_api
from app.sdk.apis import kg as kg_api
from app.sdk.apis import kw as kw_api
from app.sdk.apis import lr as lr_api
from app.sdk.apis import me as me_api
from app.sdk.apis import mg as mg_api
from app.sdk.apis import qt as qt_api
from app.sdk.apis import tx as tx_api
from app.sdk.apis import wy as wy_api
from app.sdk.apis import xm as xm_api
return {
"kw": kw_api.SUPPORT_QUALITYS,
"kg": kg_api.SUPPORT_QUALITYS,
"tx": tx_api.SUPPORT_QUALITYS,
"wy": wy_api.SUPPORT_QUALITYS,
"xm": xm_api.SUPPORT_QUALITYS,
"me": me_api.SUPPORT_QUALITYS,
"qt": qt_api.SUPPORT_QUALITYS,
"lr": lr_api.SUPPORT_QUALITYS,
"mg": mg_api.SUPPORT_QUALITYS,
"bd": bd_api.SUPPORT_QUALITYS,
"joox": joox_api.SUPPORT_QUALITYS,
"bili": bili_api.SUPPORT_QUALITYS,
"5sing": fivesing_api.SUPPORT_QUALITYS,
"apple": apple_api.SUPPORT_QUALITYS,
}
support_quality: dict[str, list[str]] = _quality_table()
async def init() -> None:
"""调用各平台的 init 钩子(无 init 的跳过;单个失败不阻塞启动,与原 init 一致)。"""
for item in SUPPORTED_SOURCES:
hook = getattr(item["module"], "init", None)
if hook is None:
continue
try:
await hook()
except Exception as err: # noqa: BLE001
print(f"[sdk] {item['id']} init 失败(已跳过): {err}")
def _trim_str(value: Any) -> Any:
return value.strip() if isinstance(value, str) else value
def _filter_str(value: Any) -> str:
if isinstance(value, str):
return re.sub(r"""\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||/|\]|\[|!|!""", "", value)
return "" if value is None else str(value)
def _get_interval_seconds(interval: Any) -> int:
if not interval:
return 0
parts = str(interval).split(":")
seconds = 0
unit = 1
while parts:
try:
seconds += int(parts.pop()) * unit
except ValueError:
pass
unit *= 60
return seconds
def _sort_single(singer: Any) -> str:
if not singer:
return ""
text = str(singer)
if _SINGERS_SEPARATOR.search(text):
return "、".join(sorted(_SINGERS_SEPARATOR.split(text)))
return text
async def search_music(
*,
name: str,
singer: str | None = None,
source: str | None = None,
only: str | None = None,
limit: int = 25,
) -> list[dict[str, Any]]:
"""多平台聚合搜索。
name 歌名;singer 歌手;source 指定平台时聚合结果跳过该平台;
only 只搜该平台(与 source 互斥,优先生效);limit 单平台结果条数。
"""
import asyncio
music_name = _trim_str(name)
tasks = []
for item in SUPPORTED_SOURCES:
module = item["module"]
if getattr(module, "music_search", None) is None:
continue
# 有声平台(no_aggregate)仅在显式 only 指定时参与搜索
if item.get("no_aggregate") and item["id"] != only:
continue
if only:
if item["id"] != only:
continue
elif item["id"] == source:
continue
keyword = f"{music_name} {singer or ''}".strip()
tasks.append(_search_one(module, keyword, limit))
results = await asyncio.gather(*tasks)
return [item for item in results if item]
async def _search_one(module: Any, keyword: str, limit: int) -> dict[str, Any] | None:
try:
return await module.music_search.search(keyword, 1, limit)
except Exception: # noqa: BLE001
return None
async def find_music(
*,
name: str,
singer: str | None = None,
album_name: str | None = None,
interval: str | None = None,
source: str | None = None,
only: str | None = None,
) -> list[dict[str, Any]]:
"""跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)。"""
lists = await search_music(name=name, singer=singer, source=source, only=only, limit=25)
f_music_name = _filter_str(name).lower()
f_singer = _filter_str(_sort_single(singer)).lower()
f_album_name = _filter_str(album_name).lower()
f_interval = _get_interval_seconds(interval)
def is_equals_interval(value: int) -> bool:
return abs((f_interval or value) - (value or f_interval)) < 5
candidates: list[dict[str, Any]] = []
for result in lists:
for item in result.get("list") or []:
item["name"] = _trim_str(item.get("name"))
item["singer"] = _trim_str(item.get("singer"))
item["_f_singer"] = _filter_str(_sort_single(item.get("singer")).lower())
item["_f_music_name"] = _filter_str(str(item.get("name") or "").lower())
item["_f_album_name"] = _filter_str(str(item.get("albumName") or "").lower())
item["_f_interval"] = _get_interval_seconds(item.get("interval"))
if not is_equals_interval(item["_f_interval"]):
item["name"] = None
continue
candidates.append(item)
matched_order: list[dict[str, Any]] = []
def take(predicate) -> None:
nonlocal candidates
hit = [item for item in candidates if predicate(item)]
rest = [item for item in candidates if not predicate(item)]
matched_order.extend(hit)
candidates = rest
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name and i.get("interval") == interval)
take(
lambda i: (
i["_f_music_name"] == f_music_name and i["_f_singer"] == f_singer and i["_f_album_name"] == f_album_name
)
)
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name)
take(lambda i: i["_f_music_name"] == f_music_name and i.get("interval") == interval)
take(lambda i: i["_f_singer"] == f_singer and i.get("interval") == interval)
take(lambda i: i.get("interval") == interval)
take(lambda i: i["_f_music_name"] == f_music_name)
take(lambda i: i["_f_singer"] == f_singer)
take(lambda i: i["_f_album_name"] == f_album_name)
for item in [*candidates, *matched_order]:
for key in ("_f_singer", "_f_music_name", "_f_album_name", "_f_interval"):
item.pop(key, None)
matched_order.extend(candidates)
return matched_order
+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"]}
+21
View File
@@ -0,0 +1,21 @@
"""账号登录模块(对应原 sdk/auth/ 与各平台 login/auth 文件)。
每个模块导出一组 async 函数,路由层只依赖这些签名:
wy:qr_key / qr_check / cellphone_login / login_by_cookie / status
kg:login_by_token / qr_key / qr_check / send_captcha / cellphone_login /
password_login / verify_user_info / status / refresh_login
tx:qr_show / qr_check / check_vip / refresh_login
lr:login / status
bili:create_qr_login / check_qr_login / status
约定:
* 成功直接返回 dict(由路由包装成 { code: 0, data })
* 可预期的业务失败抛 ApiError(401/422/502, msg)(路由原样转发状态码)
"""
from __future__ import annotations
__all__ = ["bili", "kg", "lr", "tx", "wy"]
from app.sdk.auth import bili, kg, lr, tx, wy
+223
View File
@@ -0,0 +1,223 @@
"""哔哩哔哩扫码登录(对应 sdk/bili/login.ts,上游 bilibili/login.go + account.go 的 IsVipAccount)。
接口:
* GET passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
* GET passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
凭据落盘到 auth_store 的 ``bili`` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
上游 ``New(cookie)`` 的实例化语义由 ``get_credentials('bili')`` 承担(见 platforms/bili/util.py)。
状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
"""
from __future__ import annotations
import random
import time
from typing import Any
from urllib.parse import parse_qs, urlsplit
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bili.util import REFERER, UA, as_dict
QR_GENERATE_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
QR_POLL_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
#: 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制
_CHINA_IP_FIRST = [
36,
42,
58,
60,
101,
106,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123,
124,
125,
171,
175,
180,
182,
183,
210,
211,
218,
219,
220,
221,
222,
223,
]
_CROSS_DOMAIN_KEYS = ("DedeUserID", "DedeUserID__ckMd5", "SESSDATA", "bili_jct", "sid")
def _random_china_ip() -> str:
first = random.choice(_CHINA_IP_FIRST)
return f"{first}.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}"
def _qr_headers() -> dict[str, str]:
ip = _random_china_ip()
return {"User-Agent": UA, "Referer": REFERER, "X-Forwarded-For": ip, "X-Real-IP": ip}
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
def _cookie_map_from_cross_domain(cross_url: str) -> dict[str, str]:
"""crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备)。"""
jar: dict[str, str] = {}
try:
query = parse_qs(urlsplit(str(cross_url or "")).query)
except ValueError:
return jar
for name in _CROSS_DOMAIN_KEYS:
values = query.get(name)
if values:
jar[name] = values[0]
return jar
def _cookie_string(jar: dict[str, str]) -> str:
return "; ".join(f"{key}={value}" for key, value in jar.items())
async def create_qr_login() -> dict[str, Any]:
"""创建扫码登录会话:返回 ``{key, qrurl, qrimg, expiresAt}``(qrimg 可直接展示)。"""
resp = await http_fetch(QR_GENERATE_API, headers=_qr_headers(), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"获取B站登录二维码失败(HTTP {resp.status_code})")
data = as_dict(body.get("data"))
key = str(data.get("qrcode_key") or "").strip()
if body.get("code") != 0 or not key:
message = body.get("message")
raise RuntimeError(f"获取B站登录二维码失败({body.get('code')}{f': {message}' if message else ''})")
url = str(data.get("url") or "")
qrimg = ""
try:
qrimg = to_data_url(url)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {
"key": key,
"qrurl": url,
"qrimg": qrimg,
# 上游 QRLoginSession.ExpiresAt:3 分钟有效期
"expiresAt": int(time.time()) + 180,
}
async def _fetch_profile(cookie: str) -> dict[str, Any]:
"""拉取账号资料(nav;失败不阻断登录)。"""
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav",
headers={"User-Agent": UA, "Referer": REFERER, "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
data = as_dict(body.get("data"))
return {
"mid": data.get("mid"),
"nickname": data.get("uname"),
"vipType": int(data.get("vipType") or 0),
"vipStatus": int(data.get("vipStatus") or 0),
}
except Exception: # noqa: BLE001 - 资料失败不阻断登录
return {"mid": None, "nickname": None, "vipType": 0, "vipStatus": 0}
async def _poll(key: str) -> tuple[int, list[str], Any]:
"""轮询请求(http_fetch 的 set_cookies 保留全部 Set-Cookie,登录凭据靠它取)。"""
url = f"{QR_POLL_API}?qrcode_key={key}"
resp = await http_fetch(url, headers={"User-Agent": UA, "Referer": REFERER}, timeout=15_000)
return resp.status_code, resp.set_cookies, resp.body
async def check_qr_login(key: str) -> dict[str, Any]:
"""轮询扫码状态。
返回 code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)。
"""
qrcode_key = str(key or "").strip()
if not qrcode_key:
raise RuntimeError("B站扫码 key 为空")
status, cookie_lines, body = await _poll(qrcode_key)
if status != 200:
raise ApiError(502, f"轮询B站扫码状态失败(HTTP {status})")
data = as_dict(body.get("data")) if isinstance(body, dict) else {}
code = int(data.get("code") or 0)
if code == 0:
jar = {**_cookie_map_from_cross_domain(data.get("url") or ""), **_cookie_map(cookie_lines)}
cookie = _cookie_string(jar)
if not cookie:
raise RuntimeError("B站登录成功但未获取到 Cookie,请重试")
profile = await _fetch_profile(cookie)
set_credentials(
"bili",
{
"cookie": cookie,
"refreshToken": str(data.get("refresh_token") or ""),
"mid": profile["mid"],
"nickname": profile["nickname"],
"vipType": profile["vipType"],
"vipStatus": profile["vipStatus"],
},
)
from app.sdk.apis.bili import reset_vip_cache
reset_vip_cache()
return {"code": 803, "message": "登录成功", "status": "success", "profile": profile}
status_map: dict[int, tuple[int, str, str]] = {
86038: (800, "二维码已过期", "expired"),
86090: (802, "已扫码,待确认", "scanned"),
86101: (801, "等待扫码", "waiting"),
}
if code in status_map:
mapped, message, state = status_map[code]
return {"code": mapped, "message": message, "status": state}
message = data.get("message") or f"未知状态 {code}"
return {"code": 800, "message": str(message), "status": "failed"}
def status() -> dict[str, Any]:
"""当前登录态(凭据存在性 + 落盘时保存的账号资料)。"""
cred = get_credentials("bili") or {}
if cred.get("cookie"):
return {
"logged": True,
"nickname": cred.get("nickname"),
"mid": cred.get("mid"),
"vipType": cred.get("vipType") or 0,
"updatedAt": cred.get("updatedAt"),
}
return {"logged": False}
+686
View File
@@ -0,0 +1,686 @@
"""酷狗登录(对应 sdk/auth/kg.ts)—— token 绑定 / 扫码 / 短信验证码 / 账号密码。
与 MakcRe/KuGouMusicApi 协议对齐:
* 扫码走 login-user.kugou.com 的 web 签名通道;
* 短信走 android 签名 + AES/RSA;
* 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
sid/edt 行为指纹,指纹由 kg_simulate.py 服务端生成,无需浏览器 WASM);
* token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定。
设备指纹(guid/mid/dfid)与取流共用 platforms/kg/util.py 的实现(落盘 _kg_device)。
"""
from __future__ import annotations
import base64
import json
import time
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.http.api_error import ApiError
from app.sdk.auth.kg_simulate import generate_simulate
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, to_md5
from app.sdk.platforms.kg.util import (
ANDROID_SALT,
WEB_SALT,
android_sign,
encode_uri_component,
get_device,
pkcs7_pad,
pkcs7_unpad,
random_string,
)
APPID = 1005
CLIENTVER = 11430
#: 登录专用域按参考实现固定 clientver 20489(qr/短信接口实测通过的版本)
LOGIN_CLIENTVER = 20489
SRC_APPID = 2919
QR_URL_PREFIX = "https://h5.kugou.com/apps/loginQRCode/html/index.html"
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----"""
#: 固定串派生 token 续期密钥(AES-256-CBC key / iv)
AES_KEY_SEED = "90b8382a1bb4ccdcf063102053fd75b8"
AES_IV = "f063102053fd75b8"
# login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
PWD_T1 = (
"562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c0"
"3a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5ad"
"cdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169"
"995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac"
)
PWD_T2 = (
"31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294a"
"eebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db14"
"00fdf49b7cf7e26be2e5af4da7830cc3be4"
)
PWD_T3 = "MCwwLDAsMCwwLDAsMCwwLDA="
PWD_CLIENTVER = 11440
VERIFY_VTYPES = {
16: "QQ 绑定验证",
18: "微信绑定验证",
22: "活体认证",
23: "滑块/图形验证码",
32: "手机短信验证码",
34: "人脸核身",
}
VERIFY_URL_TYPES = {
"KGCodeGT": "极验",
"KGCodeTX": "腾讯",
"KGCodeKG": "酷狗旋转",
"KGCodeKG2": "酷狗滑块",
"KGCodeSM": "数美",
"KGCodeYD": "易盾",
}
_ANDROID_UA = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi"
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(PUBLIC_KEY.encode())
return _public_key
def _js_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _rsa_pkcs1_hex(data: dict[str, Any]) -> str:
"""RSA PKCS#1 v1.5 加密(register_dev 的 p / 登录的 pk 之外的默认形态)。"""
return _load_public_key().encrypt(_js_json(data).encode(), padding.PKCS1v15()).hex()
def _rsa_no_padding_hex(data: dict[str, Any], *, pad_left: bool) -> str:
"""RSA 裸填充加密(128 字节;verify 左补零,登录/续期右补零,与参考实现一致)。"""
payload = _js_json(data).encode()
if len(payload) > 128:
raise RuntimeError("RSA 负载超长")
filler = b"\x00" * (128 - len(payload))
padded = filler + payload if pad_left else payload + filler
numbers = _load_public_key().public_numbers()
cipher = pow(int.from_bytes(padded, "big"), numbers.e, numbers.n).to_bytes(128, "big")
return cipher.hex()
def _aes_cbc_encrypt_hex(data: str, key: str, iv: str) -> str:
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv.encode()))
encryptor = cipher.encryptor()
return (encryptor.update(pkcs7_pad(data.encode())) + encryptor.finalize()).hex()
def _aes_encrypt_random(data: str | dict[str, Any]) -> dict[str, str]:
"""随机密钥 AES-256-CBC:key=md5(rand16),iv=key[16:32]。"""
temp_key = random_string(16)
key_hex = to_md5(temp_key)
text = data if isinstance(data, str) else _js_json(data)
return {"str": _aes_cbc_encrypt_hex(text, key_hex, key_hex[16:32]), "key": temp_key}
def _aes_decrypt_hex(hex_text: str, temp_key: str) -> Any:
key_hex = to_md5(temp_key)
decryptor = Cipher(algorithms.AES(key_hex.encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(bytes.fromhex(hex_text)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _playlist_aes_encrypt(data: dict[str, Any]) -> dict[str, str]:
key = random_string(6)
key_hex = to_md5(key)
cipher = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode()))
encryptor = cipher.encryptor()
payload = encryptor.update(pkcs7_pad(_js_json(data).encode())) + encryptor.finalize()
return {"key": key, "str": base64.b64encode(payload).decode()}
def _playlist_aes_decrypt(raw_base64: str, key: str) -> Any:
key_hex = to_md5(key)
decryptor = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(base64.b64decode(raw_base64)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _int_or_zero(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _gateway_headers(params: dict[str, Any], router: str | None, extra: dict[str, str] | None) -> dict[str, str]:
headers = {
"dfid": str(params["dfid"]),
"mid": str(params["mid"]),
"clienttime": str(params["clienttime"]),
"kg-rc": "1",
"kg-thash": "5d816a0",
"kg-rec": "1",
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
"User-Agent": _ANDROID_UA,
}
if router:
headers["x-router"] = router
if extra:
headers.update(extra)
return headers
async def gateway_request(
url: str,
*,
params: dict[str, Any] | None = None,
data: Any = None,
router: str | None = None,
host: str = "https://gateway.kugou.com",
headers: dict[str, str] | None = None,
with_headers: bool = False,
) -> Any:
"""网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域)。"""
dev = get_device()
all_params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
}
if params:
all_params.update(params)
signature = android_sign(all_params, _js_json(data) if data is not None else "")
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in all_params.items())
kwargs: dict[str, Any] = {
"method": "POST",
"headers": _gateway_headers(all_params, router, headers),
"timeout": 15_000,
}
if data is not None:
kwargs["body"] = data
resp = await http_fetch(f"{host}{url}?{query}&signature={signature}", **kwargs)
body = as_dict(resp.body)
if with_headers:
return body, resp.headers
return body
def _web_sign(params: dict[str, Any]) -> str:
items = sorted(f"{key}={value}" for key, value in params.items())
return to_md5(f"{WEB_SALT}{''.join(items)}{WEB_SALT}")
async def _login_user_get(url: str, extra_params: dict[str, Any] | None = None) -> dict[str, Any]:
"""login-user.kugou.com GET 请求(web 签名,与取流共用同一设备指纹)。"""
dev = get_device()
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": LOGIN_CLIENTVER,
"clienttime": int(time.time()),
}
if extra_params:
params.update(extra_params)
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://login-user.kugou.com{url}?{query}&signature={_web_sign(params)}",
headers=_gateway_headers(params, None, None),
timeout=15_000,
)
body = as_dict(resp.body)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or "酷狗登录服务请求失败"))
return body
async def verify_token(token: str, userid: str | int) -> dict[str, Any]:
"""验证 token 并返回账号资料(usercenter /v3/get_my_info,p = RSA 裸填充左补零)。"""
unix_sec = int(time.time())
p = _rsa_no_padding_hex({"token": token, "clienttime": unix_sec}, pad_left=True).upper()
body = await gateway_request(
"/v3/get_my_info",
router="usercenter.kugou.com",
params={"plat": 1},
data={"visit_time": unix_sec, "usertype": 1, "p": p, "userid": _int_or_zero(userid)},
)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data:
raise ApiError(401, str(body.get("error") or "token 无效或已过期"))
return {
"userid": data.get("userid") or _int_or_zero(userid),
"nickname": data.get("nickname") or data.get("username") or None,
"vip": bool(data["vip"]) if data.get("vip") else None,
}
async def login_by_token(token: str, userid: str | int) -> dict[str, Any]:
"""绑定并验证 kg token。"""
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
async def _apply_kg_login(token: str, userid: Any) -> dict[str, Any]:
"""保存登录凭据并拉取账号资料(扫码/短信登录共用;资料校验失败不阻断登录)。"""
try:
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
except Exception: # noqa: BLE001 - 资料校验失败不影响登录本身
uid = _int_or_zero(userid)
set_credentials("kg", {"token": token, "userid": uid})
return {"userid": uid, "nickname": None, "vip": None}
# ---- 扫码登录 ----
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码(qrimg 为可直接展示的 data URL)。"""
body = await _login_user_get(
"/v2/qrcode",
{
"appid": 1001,
"type": 1,
"plat": 4,
"qrcode_txt": f"{QR_URL_PREFIX}?appid={APPID}&",
"srcappid": SRC_APPID,
},
)
data = as_dict(body.get("data"))
key = data.get("qrcode")
if not key:
raise RuntimeError("获取二维码 key 失败")
qrurl = f"{QR_URL_PREFIX}?qrcode={key}"
qrimg = str(data.get("qrcode_img") or "")
if not qrimg:
from app.sdk.common.qrcode import to_data_url
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态(酷狗原生 0/1/2/4 → 800/801/802/803)。"""
body = await _login_user_get(
"/v2/get_userinfo_qrcode",
{"plat": 4, "appid": APPID, "srcappid": SRC_APPID, "qrcode": key},
)
data = as_dict(body.get("data"))
qr_status = data.get("status")
if qr_status == 4:
token = data.get("token")
if not token:
raise RuntimeError("登录响应中缺少 token")
return {"code": 803, "message": "登录成功", "profile": await _apply_kg_login(token, data.get("userid"))}
mapping: dict[Any, tuple[int, str]] = {0: (800, "二维码已过期"), 1: (801, "等待扫码"), 2: (802, "已扫码,待确认")}
code, message = mapping.get(qr_status, (qr_status, f"未知状态 {qr_status}"))
return {"code": code, "message": message}
# ---- 手机验证码登录 ----
def _sign_login_key(clienttime_ms: int) -> str:
return to_md5(f"{APPID}{ANDROID_SALT}{LOGIN_CLIENTVER}{clienttime_ms}")
async def send_captcha(mobile: str) -> dict[str, Any]:
"""发送短信验证码(businessid 5 = 登录场景)。"""
body = await gateway_request(
"/v7/send_mobile_code",
router="login.user.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={"businessid": 5, "mobile": str(mobile), "plat": 3},
)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or body.get("msg") or "验证码发送失败"))
return {"sent": True}
async def cellphone_login(mobile: str, code: str) -> dict[str, Any]:
"""手机验证码登录(mobile + code,成功后自动保存凭据并返回账号资料)。"""
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"mobile": str(mobile), "code": str(code)})
body = await gateway_request(
"/v7/login_by_verifycode",
host="https://loginserviceretry.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"t1": 0,
"t2": 0,
"clienttime_ms": clienttime_ms,
"mobile": f"{str(mobile)[:2]}*****{str(mobile)[10:11]}",
"key": _sign_login_key(clienttime_ms),
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
"params": enc["str"],
"t3": "MCwwLDAsMCwwLDAsMCwwLDA=",
},
headers={"User-Agent": "Android16-1070-11440-130-0-LOGIN-wifi", "support-calm": "1"},
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
data = as_dict(body.get("data"))
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"]) if data.get("secu_params") else None
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
# ---- token 自动续期 ----
async def refresh_login() -> dict[str, Any]:
"""刷新 kg token(login_by_token),成功后写回凭据存储。"""
cred = get_credentials("kg") or {}
if not cred.get("token") or str(cred.get("userid") or "") in ("", "0"):
return {"skipped": True}
clienttime = int(time.time())
p3 = _aes_cbc_encrypt_hex(
_js_json({"clienttime": clienttime, "token": cred["token"]}), to_md5(AES_KEY_SEED), AES_IV
)
enc = _aes_encrypt_random({})
pk = _rsa_no_padding_hex({"clienttime_ms": clienttime * 1000, "key": enc["key"]}, pad_left=False)
body = await gateway_request(
"/v5/login_by_token",
router="login.user.kugou.com",
params={"clientver": 20349},
data={
"p3": p3,
"params": enc["str"],
"userid": _int_or_zero(cred.get("userid")),
"need_toneinfo": 1,
"clienttime_ms": clienttime * 1000,
"dfid": "-",
"dev": "SDY-AN00",
"plat": 1,
"pk": pk,
"t1": "0",
"gitversion": "a23c277",
"t2": "0",
"t3": "MCwwLDEsMSwwLDYsMSw2LDA=",
},
headers={
"kg-thash": "6a6a1ba",
"User-Agent": "Android12-AndroidPhone-20349-201-0-ting#958959317/661004247-LOGIN-wifi",
"support-calm": "1",
},
)
data = as_dict(body.get("data"))
if body.get("error_code") != 0 or not data.get("secu_params"):
error = body.get("error")
raise RuntimeError(f"刷新失败(code {body.get('error_code')}{f': {error}' if error else ''})")
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
if not isinstance(decrypted, dict) or not decrypted.get("token"):
raise RuntimeError("刷新响应解密失败")
set_credentials("kg", {"token": decrypted["token"]})
return {"refreshed": True}
# ---- 设备 dfid 正式注册 ----
async def register_dfid() -> dict[str, Any]:
"""注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍)。
随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
协议:设备画像 → AES-128-CBC(随机 6 位 key) → base64 作 body;
p = RSA-PKCS1({aes:key, uid, token});响应为二进制密文,按 base64 解码后同密钥解密。
"""
dev = get_device()
if dev.get("dfidRegistered"):
return dev
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
device_map = {
"availableRamSize": 4983533568,
"availableRomSize": 48114719,
"availableSDSize": 48114717,
"basebandVer": "",
"batteryLevel": 100,
"batteryStatus": 3,
"brand": "Redmi",
"buildSerial": "unknown",
"device": "marble",
"imei": dev["guid"],
"imsi": "",
"manufacturer": "Xiaomi",
"uuid": dev["guid"],
"accelerometer": False,
"accelerometerValue": "",
"gravity": False,
"gravityValue": "",
"gyroscope": False,
"gyroscopeValue": "",
"light": False,
"lightValue": "",
"magnetic": False,
"magneticValue": "",
"orientation": False,
"orientationValue": "",
"pressure": False,
"pressureValue": "",
"step_counter": False,
"step_counterValue": "",
"temperature": False,
"temperatureValue": "",
}
enc = _playlist_aes_encrypt(device_map)
p = _rsa_pkcs1_hex({"aes": enc["key"], "uid": userid, "token": cred.get("token") or ""})
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"part": 1,
"platid": 1,
"p": p,
}
params["signature"] = android_sign(params, enc["str"])
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://userservice.kugou.com/risk/v2/r_register_dev?{query}",
method="POST",
headers={
"dfid": str(dev["dfid"]),
"mid": str(dev["mid"]),
"clienttime": str(params["clienttime"]),
"User-Agent": _ANDROID_UA,
},
body=enc["str"],
timeout=15_000,
)
raw_b64 = (
base64.b64encode(resp.raw).decode()
if resp.raw
else base64.b64encode(_js_json(resp.body or "").encode()).decode()
)
body = _playlist_aes_decrypt(raw_b64, enc["key"])
body = as_dict(body)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data.get("dfid"):
raise RuntimeError(f"dfid 注册失败(status {body.get('status', '未知')} {body.get('error', '')})".strip())
updated = {**dev, "dfid": str(data["dfid"]), "dfidRegistered": True}
set_credentials("_kg_device", updated)
return updated
# ---- 账号密码登录 + 20028 验证码挑战 ----
async def get_verify_info(eventid: str) -> dict[str, Any]:
"""查询验证事件详情(/verifyservice/v3/get_verify_info)。"""
cred = get_credentials("kg") or {}
body = await gateway_request(
"/verifyservice/v3/get_verify_info",
data={
"eventid": eventid,
"userid": _int_or_zero(cred.get("userid")),
"platid": 2,
"rtype": 1,
"wasm": 1,
"i": "",
"sid": "",
"edt": "",
},
)
if body.get("status") != 1 and body.get("error_code") != 0:
raise RuntimeError(str(body.get("error") or f"查询验证信息失败(code {body.get('error_code', '未知')})"))
data = body.get("data")
return data if isinstance(data, dict) else {}
async def verify_user_info(
eventid: str,
v_type: int,
verifycode: str,
sid: str | None = None,
edt: str | None = None,
) -> dict[str, Any]:
"""提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码)。"""
dev = get_device()
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
fingerprint = {"sid": sid, "edt": edt} if sid and edt else generate_simulate(dev["mid"], userid, dev["dfid"])
data_map: dict[str, Any] = {
"eventid": eventid,
"userid": userid,
"platid": 2,
"v_type": _int_or_zero(v_type) or 23,
"wasm": 1,
"i": "",
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
enc = _aes_encrypt_random({"code": verifycode} if data_map["v_type"] == 32 else {})
if data_map["v_type"] == 32:
# 手机短信验证码:明文 code + 密文 params 均携带
data_map.update({"code": verifycode, "verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
else:
# 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
data_map.update({"verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
data_map["params"] = enc["str"]
body = await gateway_request(
"/v4/verify_user_info",
host="https://verifyservice.kugou.com",
params={"clientver": 11510},
data=data_map,
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"验证提交失败(code {body.get('error_code', '未知')})"))
return {"verified": True}
async def password_login(username: str, password: str, code: str | None = None) -> dict[str, Any]:
"""账号密码登录(/v9/login_by_pwd);命中 20028 风控返回验证码挑战。"""
# 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
try:
await register_dfid()
except Exception: # noqa: BLE001 - 注册失败不阻断登录
pass
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"pwd": str(password), "code": str(code or ""), "clienttime_ms": clienttime_ms})
body, headers = await gateway_request(
"/v9/login_by_pwd",
router="login.user.kugou.com",
params={"clientver": PWD_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"clienttime_ms": clienttime_ms,
"t1": PWD_T1,
"t2": PWD_T2,
"t3": PWD_T3,
"dev": "SDY-AN00",
"username": str(username),
"params": enc["str"],
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
},
headers={"User-Agent": f"Android16-1070-{PWD_CLIENTVER}-130-0-LOGIN-wifi", "support-calm": "1"},
with_headers=True,
)
data = as_dict(body.get("data"))
if body.get("status") == 1 and data.get("secu_params"):
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
if body.get("error_code") == 20028:
eventid = str(headers.get("ssa-code") or body.get("ssaCode") or "")
if not eventid:
raise RuntimeError("触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录")
dev = get_device()
cred = get_credentials("kg") or {}
fingerprint = generate_simulate(dev["mid"], _int_or_zero(cred.get("userid")), dev["dfid"])
try:
info = await get_verify_info(eventid)
except Exception: # noqa: BLE001 - 挑战详情失败时按默认类型返回
info = {}
v_type = _int_or_zero(info.get("v_type")) or 23
url = str(info.get("url") or "")
provider = next((key for key in VERIFY_URL_TYPES if key in url), "")
challenge: dict[str, Any] = {
"eventid": eventid,
"v_type": v_type,
"vTypeLabel": VERIFY_VTYPES.get(v_type, f"未知类型 {v_type}"),
"url": url or None,
"provider": VERIFY_URL_TYPES[provider] if provider else None,
"txappid": info.get("txappid"),
"sessionid": info.get("sessionid"),
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
return challenge
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
async def status() -> dict[str, Any] | None:
"""当前 kg 登录资料(未登录返回 None)。"""
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"userid": cred.get("userid"), "nickname": cred.get("nickname")}
+161
View File
@@ -0,0 +1,161 @@
"""酷狗行为指纹模拟(对应 sdk/auth/kgSimulate.ts)—— sid/edt 生成。
移植自 MakcRe/KuGouMusicApi util/generate_simulate.js,用途:20028 二次验证时替代浏览器 WASM 上报行为数据。
协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流>
* edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64
* sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制)
事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔,
每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。
"""
from __future__ import annotations
import base64
import math
import random
import time
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.utils import to_md5
from app.sdk.platforms.kg.util import pkcs7_pad, random_string
SIM_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF
lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/
W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA
2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW
PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc
56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa
rQIDAQAB
-----END PUBLIC KEY-----"""
SIM_IV = b"kugousecurity123"
def _ri(low: int, high: int) -> int:
"""JS ``Math.floor(Math.random() * (max - min + 1)) + min``。"""
return math.floor(random.random() * (high - low + 1)) + low
def _round(value: float) -> int:
"""JS Math.round(.5 向上取整,不是 Python 的银行家舍入)。"""
return math.floor(value + 0.5)
_sentinel = 0xFFFFFFFF - _ri(0, 20)
def _f3(t: int | str, i: int, x: int, y: int) -> str:
return f"3,{t},{i},{x},{y}"
def _f5(t: int | str, i: int) -> str:
return f"5,{t},{i}"
def _f6(t: int | str, i: int, x: int, y: int) -> str:
return f"6,{t},{i},{x},{y}"
def _bezier_path(sx: float, sy: float, ex: float, ey: float, n: int) -> list[dict[str, float]]:
"""三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛。"""
c1x = sx + (ex - sx) * 0.3 + _ri(-80, 80)
c1y = sy + (ey - sy) * 0.2 + _ri(-60, 60)
c2x = sx + (ex - sx) * 0.7 + _ri(-60, 60)
c2y = sy + (ey - sy) * 0.8 + _ri(-40, 40)
points: list[dict[str, float]] = []
for i in range(n + 1):
t = i / n
u = 1 - t
x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
jitter = max(0.5, 3 - t * 2.5)
points.append(
{
"x": x + (random.random() - 0.5) * jitter,
"y": y + (random.random() - 0.5) * jitter,
}
)
return points
def _generate_edt_data(start_x: int, start_y: int, end_x: int, end_y: int, mouse_points: int) -> str:
entries: list[str] = []
ts = 0
ei = 0
entries.extend([_f5(0, 0), _f5(_sentinel, 0), _f5(0, 0), _f5(_sentinel, 0)])
ts += _ri(5, 20)
entries.extend([_f6(ts, ei, 750, 500), _f6(_sentinel, ei, 750, 500)])
ei += 1
for _ in range(3):
ts += _ri(80, 600)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
path = _bezier_path(start_x, start_y, end_x, end_y, mouse_points)
si = 0
for i, point in enumerate(path):
ts += _ri(8, 50)
rx = _round(point["x"])
ry = _round(point["y"])
entries.extend([_f3(ts, si, rx, ry), _f3(_sentinel, si, rx, ry)])
if i > 0 and i % 12 == 0:
ts += _ri(20, 60)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
si = (si + 1) % 2
ts += _ri(5, 30)
entries.extend(
[
_f3(ts, 1, _round(end_x + _ri(-5, 5)), _round(end_y + _ri(-5, 5))),
_f3(_sentinel, 1, _round(end_x), _round(end_y)),
]
)
return ":".join(entries)
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(SIM_PUBLIC_KEY.encode())
return _public_key
def generate_simulate(mid: str | int, userid: str | int, dfid: str, webgl_hash: str = "") -> dict[str, str]:
"""生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送。"""
global _sentinel
_sentinel = 0xFFFFFFFF - _ri(0, 20)
key = to_md5(random_string(16))[:16]
points = _ri(30, 60)
start_x = _ri(200, 600)
start_y = _ri(200, 500)
end_x = _ri(500, 700)
end_y = _ri(80, 150)
webgl = webgl_hash or to_md5(random_string(32))
edt_data = _generate_edt_data(start_x, start_y, end_x, end_y, points)
plaintext = (
f"mid={mid or 0};userid={userid or 0};dfid={dfid or 0};webgl={webgl};"
f"webdriver=0;ts={int(time.time() * 1000)};data={edt_data}"
)
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(SIM_IV))
encryptor = cipher.encryptor()
edt = base64.b64encode(encryptor.update(pkcs7_pad(plaintext.encode())) + encryptor.finalize()).decode()
sid = base64.b64encode(
_load_public_key().encrypt(
key.encode(),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
)
).decode()
return {"edt": edt, "sid": sid}
+59
View File
@@ -0,0 +1,59 @@
"""懒人听书登录(对应 sdk/lr/auth.ts,web 通道 m.lrts.me/ajax/logon)。
参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
(免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
"""
from __future__ import annotations
import base64
from typing import Any
from urllib.parse import quote
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
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"
)
LOGON_URL = "https://m.lrts.me/ajax/logon"
async def login(account: str, password: str) -> dict[str, Any]:
"""账号密码登录(成功保存会话 Cookie)。"""
form = f"account={quote(str(account), safe='')}&pwd={base64.b64encode(str(password).encode()).decode()}"
resp = await http_fetch(
LOGON_URL,
method="POST",
headers={
"User-Agent": UA_MI,
"Referer": "https://m.lrts.me/",
"Content-Type": "application/x-www-form-urlencoded",
},
body=form,
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not body.get("phone"):
raise ApiError(401, str(body.get("msg") or "登录失败(账号或密码错误)"))
cookie = "; ".join(part for part in (item.split(";")[0].strip() for item in resp.set_cookies) if part)
if not cookie:
raise RuntimeError("登录成功但未返回会话 Cookie")
set_credentials("lr", {"cookie": cookie, "phone": str(body["phone"])})
return {"logged": True, "phone": str(body["phone"])}
def get_lr_cookie() -> str:
"""登录会话 Cookie(未登录返回空串)。"""
return str((get_credentials("lr") or {}).get("cookie") or "")
def status() -> dict[str, Any]:
"""登录状态。"""
cred = get_credentials("lr") or {}
if cred.get("cookie"):
return {"logged": True, "phone": cred.get("phone"), "updatedAt": cred.get("updatedAt")}
return {"logged": False}
+382
View File
@@ -0,0 +1,382 @@
"""QQ 音乐凭据(对应 sdk/auth/tx.ts)—— cookie 解析、扫码登录、VIP 检测与登录态续期。
续期走 musicu.fcg 未签名通道(与 apis/tx.py 一致);Login/VipLogin 模块若拒绝未签名请求
则以 code != 0 失败,此时保留原凭据并抛错,由调用方记录日志。
续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试,失败率较高;
完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。
扫码登录(参考 sansenjian/qq-music-api):ptlogin 扫码 → check_sig →
graph.qq.com OAuth authorize 换 code → QQConnectLogin.LoginServer/QQLogin 换凭据,
成功响应体一般携带 refresh_key 等完整字段,可走自动续期。
"""
from __future__ import annotations
import base64
import random
import re
import time
from typing import Any
from urllib.parse import urlencode
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)",
"Referer": "https://y.qq.com/",
}
def parse_cookie(cookie: str) -> dict[str, str]:
"""解析 cookie 中的关键字段(uin / qm_keyst 即 musickey / tmeLoginType)。"""
text = str(cookie or "")
uin = re.search(r"uin=(\d+)", text)
musickey = re.search(r"qm_keyst=([^;]+)", text)
login_type = re.search(r"tmeLoginType=(\d+)", text)
return {
"uin": uin.group(1) if uin else "0",
"musickey": musickey.group(1) if musickey else "",
"tmeLoginType": login_type.group(1) if login_type else "",
}
async def _musicu_request(data: dict[str, Any], cred: dict[str, Any] | None) -> dict[str, Any]:
headers = dict(HEADERS)
cookie = str((cred or {}).get("cookie") or "")
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers=headers,
body=data,
timeout=15_000,
)
return resp.body if isinstance(resp.body, dict) else {}
async def check_vip() -> str | None:
"""检测当前账号 VIP 等级(vip_login_base),结果写入凭据存储。
成功返回 'svip' / 'vip' / 'normal';请求失败返回 None(不改动已存值)。
"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return None
uin = parse_cookie(str(cred["cookie"]))["uin"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": {}},
},
cred,
)
req = as_dict(body.get("req"))
if req.get("code") != 0:
return None
data = as_dict(req.get("data"))
identity = as_dict(data.get("identity"))
if identity.get("HugeVip"):
vip_type = "svip"
elif identity.get("LMFlag"):
vip_type = "vip"
else:
vip_type = "normal"
set_credentials("tx", {"vipType": vip_type})
return vip_type
async def refresh_login() -> dict[str, Any]:
"""续期 tx 登录态(music.login.LoginServer),成功后写回新 musickey 并顺带检测 VIP。"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return {"skipped": True}
parsed = parse_cookie(str(cred["cookie"]))
uin, musickey = parsed["uin"], parsed["musickey"]
if not musickey:
raise RuntimeError("cookie 缺少 qm_keyst 字段")
param: dict[str, Any] = {
"musicid": int(uin) if uin.isdigit() else 0,
"musickey": musickey,
"loginMode": 2,
"expired_in": 0,
}
# App 抓包的完整凭据(存在才携带)
if cred.get("openId"):
param["openid"] = cred["openId"]
if cred.get("accessToken"):
param["access_token"] = cred["accessToken"]
if cred.get("refreshToken"):
param["refresh_token"] = cred["refreshToken"]
if cred.get("refreshKey"):
param["refresh_key"] = cred["refreshKey"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "music.login.LoginServer", "method": "Login", "param": param},
},
cred,
)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
if req.get("code") != 0 or not data.get("musickey"):
msg = f": {req['msg']}" if req.get("msg") else ""
raise RuntimeError(f"刷新失败(code {req.get('code')}{msg})")
# musickey 可能含 $ 等字符:用函数替换,避免字符串替换里的分组转义被展开
cookie = re.sub(r"qm_keyst=[^;]*", lambda _match: f"qm_keyst={data['musickey']}", str(cred["cookie"]))
updated: dict[str, Any] = {"cookie": cookie, "uin": str(data.get("musicid") or uin)}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响续期结果
vip_type = None
return {"refreshed": True, **({"vipType": vip_type} if vip_type else {})}
# ---------------- 扫码登录 ----------------
def _int32(value: int) -> int:
"""JS 位运算的 ToInt32 语义。"""
value &= 0xFFFFFFFF
return value - 0x100000000 if value >= 0x80000000 else value
def _hash33(text: str) -> int:
"""ptqrtoken:hash33(qrsig)。"""
hashed = 0
for char in text:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _get_gtk(skey: str) -> int:
"""g_tk:p_skey 的 5381 哈希。"""
hashed = 5381
for char in skey:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _random_guid() -> str:
def repl(match: re.Match[str]) -> str:
value = int(random.random() * 16) | 0
if match.group(0) == "y":
value = (value & 0x3) | 0x8
return format(value, "X")
return re.sub(r"[xy]", repl, "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx")
class _CookieJar:
"""登录链路共享的 cookie 罐:同名后到优先,存「name=value」对。"""
def __init__(self) -> None:
self._jar: dict[str, str] = {}
def merge(self, set_cookies: list[str] | None) -> None:
for item in set_cookies or []:
pair = str(item).split(";")[0]
name = pair.split("=")[0].strip() if "=" in pair else ""
if name:
self._jar[name] = pair
def get(self, name: str) -> str:
pair = self._jar.get(name)
return "=".join(pair.split("=")[1:]) if pair else ""
def entries(self) -> list[tuple[str, str]]:
return list(self._jar.items())
def header(self) -> str:
return "; ".join(self._jar.values())
class _PtloginError(RuntimeError):
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
async def _ptlogin_request(path: str, query: dict[str, str], cookie: str) -> Any:
url = f"https://ssl.ptlogin2.qq.com/{path}?{urlencode(query)}"
kwargs: dict[str, Any] = {"timeout": 15_000}
if cookie:
kwargs["headers"] = {"cookie": cookie}
resp = await http_fetch(url, **kwargs)
if resp.status_code != 200:
raise _PtloginError(resp.status_code, f"ptlogin {path} 失败(HTTP {resp.status_code})")
return resp
async def qr_show() -> dict[str, Any]:
"""创建扫码登录二维码(客户端扫码后轮询 qr_check),qrsig 需原样回传。"""
resp = await http_fetch(
"https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4"
f"&t={random.random()}&daid=383&pt_3rd_aid=100497308"
"&u1=https%3A%2F%2Fgraph.qq.com%2Foauth2.0%2Flogin_jump",
timeout=15_000,
)
qrsig = ""
for item in resp.set_cookies:
match = re.search(r"qrsig=([^;]+)", item)
if match:
qrsig = match.group(1)
break
if not qrsig:
raise RuntimeError("获取二维码失败(响应缺少 qrsig)")
return {"qrimg": f"data:image/png;base64,{base64.b64encode(resp.raw).decode()}", "qrsig": qrsig}
async def qr_check(qrsig: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存)。"""
if not qrsig:
raise ApiError(422, "缺少 qrsig")
jar = _CookieJar()
jar.merge([f"qrsig={qrsig}"])
# 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中
# qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理
try:
poll = await _ptlogin_request(
"ptqrlogin",
{
"u1": "https://graph.qq.com/oauth2.0/login_jump",
"ptqrtoken": str(_hash33(qrsig)),
"ptredirect": "0",
"h": "1",
"t": "1",
"g": "1",
"from_ui": "1",
"ptlang": "2052",
"action": f"0-0-{int(time.time() * 1000)}",
"js_ver": "23111510",
"js_type": "1",
"pt_uistyle": "40",
"aid": "716027609",
"daid": "383",
"pt_3rd_aid": "100497308",
"o1vId": _random_guid(),
},
jar.header(),
)
except _PtloginError as err:
if err.status_code == 403:
return {"code": 800, "message": "二维码已失效"}
raise
jar.merge(poll.set_cookies)
text = poll.body if isinstance(poll.body, str) else ""
match = re.search(r"ptuiCB\('(\d+)','[^']*','([^']*)'", text)
state = match.group(1) if match else ""
if state != "0":
states = {
"65": (800, "二维码已失效"),
"66": (801, "等待扫码"),
"67": (802, "已扫码,待确认"),
"68": (802, "授权中"),
}
code, message = states.get(state, (801, "等待扫码"))
return {"code": code, "message": message}
# 2. check_sig 换 p_skey(g_tk 需要)
check_sig_url = match.group(2) if match else ""
if not check_sig_url.startswith("http"):
raise RuntimeError("登录检查失败(未返回 check_sig 地址)")
check_sig = await http_fetch(check_sig_url, headers={"cookie": jar.header()}, timeout=15_000)
jar.merge(check_sig.set_cookies)
p_skey = jar.get("p_skey")
if not p_skey:
raise RuntimeError("登录检查失败(响应缺少 p_skey)")
# 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台)
authorize = await http_fetch(
"https://graph.qq.com/oauth2.0/authorize",
method="POST",
headers={"Referer": "https://graph.qq.com/oauth2.0/authorize", "cookie": jar.header()},
form={
"response_type": "code",
"client_id": "100497308",
"redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/",
"scope": "get_user_info,get_app_friends",
"state": "state",
"switch": "",
"from_ptlogin": "1",
"src": "1",
"update_auth": "1",
"openapi": "1010_1030",
"g_tk": str(_get_gtk(p_skey)),
"auth_time": str(int(time.time() * 1000)),
"ui": _random_guid(),
},
timeout=15_000,
)
jar.merge(authorize.set_cookies)
location = authorize.headers.get("location", "")
code_match = re.search(r"[?&]code=([^&]+)", location)
if not code_match:
raise RuntimeError("OAuth 授权失败(未取得 code)")
code = code_match.group(1)
# 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段)
login = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers={**HEADERS, "cookie": jar.header()},
body={
"comm": {"g_tk": _get_gtk(p_skey), "platform": "yqq", "ct": 24, "cv": 0},
"req": {"module": "QQConnectLogin.LoginServer", "method": "QQLogin", "param": {"code": code}},
},
timeout=15_000,
)
jar.merge(login.set_cookies)
body = as_dict(login.body)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
musickey = str(data.get("musickey") or jar.get("qm_keyst") or "")
if not musickey:
raise RuntimeError("登录失败(未取得 musickey)")
# ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉
uin = re.sub(r"^o0*", "", str(data.get("musicid") or jar.get("uin") or "")) or "0"
# 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声)
pairs = [f"uin={uin}", f"qm_keyst={musickey}"]
if data.get("encryptUin"):
pairs.append(f"euin={data['encryptUin']}")
skip = {"qrsig", "ptui_loginuin", "uin", "qm_keyst", "euin"}
for name, pair in jar.entries():
if name not in skip:
pairs.append(pair)
updated: dict[str, Any] = {"cookie": "; ".join(pairs), "uin": uin}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_token"):
updated["refreshToken"] = str(data["refresh_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响登录结果
vip_type = None
return {"code": 803, "message": "登录成功", "uin": uin, **({"vipType": vip_type} if vip_type else {})}
+166
View File
@@ -0,0 +1,166 @@
"""网易云登录(对应 sdk/auth/wy.ts)—— 扫码 / 手机号 / cookie 绑定,weapi 加密请求。
参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)。
二维码接口只提供明文 api 形态(weapi 形态已 404);其余登录接口走 weapi。
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
from app.http.api_error import ApiError
from app.sdk.auth_store import get_wy_cookie, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
from app.sdk.platforms.wy.utils.crypto import weapi
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
async def _request_weapi(path: str, data: Any, cookie: str | None = None) -> tuple[Any, list[str]]:
ck = get_wy_cookie() if cookie is None else cookie
headers = {
"User-Agent": UA,
"origin": "https://music.163.com",
"Referer": "https://music.163.com/",
}
if ck:
headers["cookie"] = ck
resp = await http_fetch(
f"https://music.163.com/weapi{path}",
method="POST",
headers=headers,
form=weapi(data),
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
async def _request_api(path: str, data: Any) -> tuple[Any, list[str]]:
"""明文 api 请求(二维码接口只提供该形态)。"""
resp = await http_fetch(
f"https://music.163.com{path}",
method="POST",
headers={"User-Agent": UA, "origin": "https://music.163.com", "Referer": "https://music.163.com/"},
form=data,
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
"""从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U。"""
joined = "; ".join(part for part in (item.split(";")[0].strip() for item in set_cookie) if part)
match = re.search(r"MUSIC_U=([^;]+)", joined)
return joined, match.group(1) if match else ""
async def _fetch_profile() -> dict[str, Any]:
"""登录成功后拉取账号资料。"""
body, _ = await _request_weapi("/api/w/nuser/account/get", {})
body = as_dict(body)
account = as_dict(body.get("account"))
profile = as_dict(body.get("profile"))
return {
"userid": account.get("id"),
"nickname": profile.get("nickname"),
"avatar": profile.get("avatarUrl"),
"vipType": profile.get("vipType"),
}
async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
"""应用登录 cookie(成功后调用)。"""
cookie, music_u = _extract_login_cookie(set_cookie)
if not music_u:
raise RuntimeError("登录响应中缺少 MUSIC_U")
profile = await _fetch_profile()
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
return profile
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码内容。"""
body, _ = await _request_api("/api/login/qrcode/unikey", {"type": 3})
body = as_dict(body)
if body.get("code") != 200 or not body.get("unikey"):
raise RuntimeError("获取二维码 key 失败")
key = str(body["unikey"])
qrurl = f"https://music.163.com/login?codekey={key}"
qrimg = ""
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)。"""
body, set_cookie = await _request_api("/api/login/qrcode/client/login", {"key": key, "type": 3})
body = as_dict(body)
code = body.get("code")
if code == 803:
profile = await _apply_login(set_cookie)
return {"code": 803, "message": "登录成功", "profile": profile}
messages: dict[Any, str] = {800: "二维码已过期", 801: "等待扫码", 802: "已扫码,待确认"}
message = messages.get(code) or body.get("message") or "未知状态"
return {"code": code, "message": message}
async def cellphone_login(
phone: str, password: str | None = None, captcha: str | None = None, countrycode: str = "86"
) -> dict[str, Any]:
"""手机号登录(密码明文传入,本地 MD5 后提交;或直接传验证码 captcha)。"""
data: dict[str, Any] = {
"type": "1",
"https": "true",
"phone": phone,
"countrycode": countrycode,
"remember": "true",
}
if captcha:
data["captcha"] = captcha
else:
data["password"] = hashlib.md5(str(password or "").encode()).hexdigest()
body, set_cookie = await _request_weapi("/api/w/login/cellphone", data)
body = as_dict(body)
if body.get("code") != 200:
raise ApiError(401, str(body.get("message") or body.get("msg") or "登录失败"))
return await _apply_login(set_cookie)
async def login_by_cookie(cookie_input: str) -> dict[str, Any]:
"""手动绑定 cookie(须含 MUSIC_U);绑定前用 account/get 实测校验。"""
cookie = str(cookie_input or "").strip()
match = re.search(r"MUSIC_U=([^;\s]+)", cookie)
if not match:
raise ApiError(422, "cookie 需包含 MUSIC_U 字段")
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
body = as_dict(body)
account = as_dict(body.get("account"))
if not account.get("id"):
raise ApiError(401, "cookie 无效或已过期(MUSIC_U 校验未通过)")
profile_body = as_dict(body.get("profile"))
profile = {
"userid": account.get("id"),
"nickname": profile_body.get("nickname"),
"avatar": profile_body.get("avatarUrl"),
"vipType": profile_body.get("vipType"),
}
set_credentials("wy", {"cookie": cookie, "musicU": match.group(1), **profile})
return profile
async def status() -> dict[str, Any] | None:
"""当前账号资料(未登录返回 None)。"""
if not get_wy_cookie():
return None
return await _fetch_profile()
+130
View File
@@ -0,0 +1,130 @@
"""登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)。
结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + 原子替换」,
避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
"""
from __future__ import annotations
import json
import os
import threading
import time
from datetime import UTC
from typing import Any
from app.config import data_dir
_lock = threading.Lock()
_cache: dict[str, Any] = {}
_loaded = False
def _data_file():
return data_dir() / "auth.json"
def _load() -> dict[str, Any]:
global _cache, _loaded
if _loaded:
return _cache
path = _data_file()
if not path.exists():
_cache = {}
_loaded = True
return _cache
try:
_cache = json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError) as err:
# 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
# 否则下一次 set_credentials 就会用空表覆盖掉本可恢复的文件。
backup = path.with_name(f"{path.name}.corrupt-{int(time.time() * 1000)}")
try:
path.rename(backup)
print(f"[auth/store] {path} 解析失败,已备份为 {backup},本次以空凭据启动: {err}")
except OSError:
print(f"[auth/store] {path} 解析失败且备份未成功,本次以空凭据启动: {err}")
_cache = {}
_loaded = True
return _cache
def _save(data: dict[str, Any]) -> None:
global _cache, _loaded
_cache = data
_loaded = True
path = _data_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=2))
os.replace(tmp, path)
except OSError:
try:
tmp.unlink()
except OSError:
pass
raise
def get_credentials(platform: str) -> dict[str, Any] | None:
with _lock:
return _load().get(platform)
def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
with _lock:
data = _load()
merged = {**data.get(platform, {}), **credentials, "updatedAt": _iso_now()}
data[platform] = merged
_save(data)
def clear_credentials(platform: str) -> None:
with _lock:
data = _load()
data.pop(platform, None)
_save(data)
def _iso_now() -> str:
from datetime import datetime
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# ---- 各平台便捷读法(未登录返回空值,与原文一致) ----
def get_wy_cookie() -> str:
cred = get_credentials("wy") or {}
return str(cred.get("cookie") or "")
def get_kg_auth() -> dict[str, Any] | None:
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"token": cred["token"], "userid": cred.get("userid") or 0}
def get_tx_auth() -> dict[str, Any] | None:
import re
cred = get_credentials("tx") or {}
cookie = str(cred.get("cookie") or "")
if not cookie:
return None
match = re.search(r"uin=(\d+)", cookie)
return {"cookie": cookie, "uin": match.group(1) if match else "0"}
def get_cookie(platform: str) -> str:
"""通用 cookie 读取(xm/me/lr/mg/bd/joox/apple/5sing 等)。"""
cred = get_credentials(platform) or {}
return str(cred.get("cookie") or "").strip()
+1
View File
@@ -0,0 +1 @@
"""共享基础设施(请求层、工具函数)。"""
+36
View File
@@ -0,0 +1,36 @@
"""二维码生成 —— 把登录 URL 渲染成 data URL,供 /login/* 的 qrimg 字段直接展示。
对应原 TS 里 `import qrcode from 'qrcode'` 的 `toDataURL` 用法(bili 扫码登录)。
用 Pillow 后端输出 PNG,再 base64 成 data URL,前端 <img src> 可直接用。
"""
from __future__ import annotations
import base64
import io
import qrcode as _qrcode
from qrcode.constants import ERROR_CORRECT_M
def to_data_url(text: str, *, box_size: int = 6, border: int = 2) -> str:
"""生成 `data:image/png;base64,...`。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def to_png_bytes(text: str, *, box_size: int = 6, border: int = 2) -> bytes:
"""生成 PNG 原始字节(需要直接返回图片响应时用)。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
return buffer.getvalue()
+224
View File
@@ -0,0 +1,224 @@
"""网络请求层。
对应原 sdk/common/request.ts,但把 Node 的 `{ promise, cancelHttp }` 句柄收敛为
原生 `async def` —— 取消语义由调用方用 `asyncio.wait_for` / task 取消表达。
保持与原文一致的行为细节:
* 不跟随重定向(Node http 默认行为;需要跟随的调用方自行处理 Location)
* 不保留 cookie(每次请求独立;各平台自己管理 Cookie 头,签名依赖这一点)
* body:dict/list 自动 JSON 序列化,str/bytes 原样发送;form 走 urlencoded
* 未显式给 content-type 时按 body 形式补 application/json / x-www-form-urlencoded
* 响应体:无论 content-type 都先尝试 JSON 解析,失败返回文本
* 64MB 响应上限
* 错误文案与原文一致(超时 / 无法连接到服务器 / 请求失败)
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import aiohttp
MAX_BUFFER_SIZE = 64 * 1024 * 1024
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36"
),
}
MSG_CANCEL = "请求已取消"
MSG_FAIL = "请求失败"
MSG_TIMEOUT = "请求超时"
MSG_NOT_CONNECT = "无法连接到服务器"
@dataclass
class HttpResponse:
status_code: int
headers: dict[str, str] = field(default_factory=dict)
body: Any = None
raw: bytes = b""
#: 原始多值 Set-Cookie(headers 折叠同名头后只留最后一个,登录链路必须按数组读)
set_cookies: list[str] = field(default_factory=list)
@property
def statusCode(self) -> int: # 兼容原文命名
return self.status_code
def header(self, name: str, default: str = "") -> str:
return self.headers.get(name.lower(), default)
def json(self) -> Any:
"""把 body 当 JSON 取;body 已是结构时原样返回。"""
return self.body
_session: aiohttp.ClientSession | None = None
_session_loop: asyncio.AbstractEventLoop | None = None
def _get_session() -> aiohttp.ClientSession:
"""进程级连接池。禁用 cookie jar 与自动重定向,语义对齐 Node http。
trust_env=False:Node 的 http/https 不读系统代理,而 aiohttp 在 Windows 上会通过
注册表拿到「Internet 选项」里的代理(本机是 127.0.0.1:7890)。走该代理会让部分域名
(如 xm 的 hdaa.shuzilm.cn)连不上,且所有请求的出口 IP/风控画像与 TS 版不一致,
因此这里显式直连,与 Node 行为保持一致。
"""
global _session, _session_loop
loop = asyncio.get_running_loop()
if _session is None or _session.closed or _session_loop is not loop:
_session = aiohttp.ClientSession(
cookie_jar=aiohttp.DummyCookieJar(),
auto_decompress=True,
trust_env=False,
connector=aiohttp.TCPConnector(limit=200, ttl_dns_cache=300),
)
_session_loop = loop
return _session
async def close_session() -> None:
global _session
if _session is not None and not _session.closed:
await _session.close()
_session = None
def _parse_body(raw: bytes) -> Any:
if not raw:
return None
text = raw.decode("utf-8", errors="replace")
try:
return json.loads(text)
except (ValueError, TypeError):
return text
def _build_body(options: Mapping[str, Any]) -> tuple[bytes | None, bool]:
form = options.get("form")
if form:
from urllib.parse import urlencode
pairs: list[tuple[str, str]] = []
for key, value in form.items():
if value is None:
continue
# 对象值对齐 URLSearchParams 的 JSON.stringify(紧凑、无空格)
pairs.append(
(
key,
json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, (dict, list))
else str(value),
)
)
return urlencode(pairs).encode(), True
body = options.get("body")
if body is None:
return None, False
if isinstance(body, bytes):
return body, False
if isinstance(body, str):
return body.encode(), False
# 必须与 JS 的 JSON.stringify 逐字节一致(紧凑分隔符):
# 部分平台(kg 网关)的 signature 就是按紧凑 JSON 计算的,空格会导致验签失败。
return json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode(), False
def _flatten_headers(headers: Mapping[str, Any]) -> dict[str, str]:
return {str(k): str(v) for k, v in headers.items()}
async def http_fetch(url: str, **options: Any) -> HttpResponse:
"""发起 HTTP 请求并返回完整响应。
options: method / headers / body / form / timeout(毫秒) / lookup / family
"""
method = str(options.get("method") or "GET").upper()
payload, is_form = _build_body(options)
headers = {**DEFAULT_HEADERS, **_flatten_headers(options.get("headers") or {})}
lowered = {k.lower() for k in headers}
if payload is not None:
if "content-type" not in lowered:
headers["content-type"] = "application/x-www-form-urlencoded" if is_form else "application/json"
if "content-length" not in lowered:
headers["content-length"] = str(len(payload))
timeout_ms = options.get("timeout")
timeout = aiohttp.ClientTimeout(total=(timeout_ms / 1000) if timeout_ms else 60)
session = _get_session()
try:
async with session.request(
method,
url,
headers=headers,
data=payload,
timeout=timeout,
allow_redirects=False,
) as resp:
chunks: list[bytes] = []
size = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
size += len(chunk)
if size > MAX_BUFFER_SIZE:
raise RuntimeError("Response too large")
chunks.append(chunk)
raw = b"".join(chunks)
return HttpResponse(
status_code=resp.status,
headers={k.lower(): v for k, v in resp.headers.items()},
body=_parse_body(raw),
raw=raw,
set_cookies=list(resp.headers.getall("set-cookie", [])),
)
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorDNSError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientConnectionError as err:
if isinstance(err, aiohttp.ServerDisconnectedError):
raise ConnectionError(MSG_FAIL) from err
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_probe(url: str, *, headers: Mapping[str, Any] | None = None, timeout_ms: int = 8000) -> tuple[int, str]:
"""只读响应头的探测请求:返回 ``(status, location)``,**不消费 body**。
取流 CDN 探测专用(bili):CDN 忽略 Range 时会尝试把整段音频推下来,
等 http_fetch 读完 body 就等于把文件下载完(点歌卡死)。拿到响应头即退出上下文,
aiohttp 随即中止并关闭连接。不跟随重定向,调用方用 location 自行跟跳(对应 Node 的 manual)。
"""
timeout_obj = aiohttp.ClientTimeout(total=timeout_ms / 1000)
session = _get_session()
try:
async with session.request(
"GET",
url,
headers={**DEFAULT_HEADERS, **_flatten_headers(headers or {})},
timeout=timeout_obj,
allow_redirects=False,
) as resp:
return resp.status, resp.headers.get("location", "")
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_get_json(url: str, **options: Any) -> Any:
"""GET 并返回解析后的 body。"""
options.setdefault("method", "get")
resp = await http_fetch(url, **options)
return resp.body
+158
View File
@@ -0,0 +1,158 @@
"""通用工具函数(对应原 sdk/common/utils.ts 与 sdk/utils.ts)。"""
from __future__ import annotations
import hashlib
import math
import re
import time
from collections.abc import Iterable, Mapping
from datetime import datetime
from typing import Any
def to_md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def as_dict(value: Any) -> dict[str, Any]:
"""上游 ``x?.field ?? {}`` 的安全取法:非 dict 一律当空 dict。"""
return value if isinstance(value, dict) else {}
def as_list(value: Any) -> list[Any]:
"""上游 ``x ?? []`` 的安全取法:非 list 一律当空列表。"""
return value if isinstance(value, list) else []
def size_formate(size: float | int | None) -> str:
"""将字节数格式化为可读字符串。"""
if not size:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
number = math.floor(math.log(size) / math.log(1024))
number = min(number, len(units) - 1)
return f"{size / math.pow(1024, number):.2f} {units[number]}"
def format_play_time(seconds: float | int | str | None) -> str:
"""秒数 → mm:ss;0 返回 --/--。"""
try:
value = float(seconds) # type: ignore[arg-type]
except (TypeError, ValueError):
return "--/--"
if math.isnan(value) or value < 0:
value = 0
minutes = int(value // 60)
secs = int(value % 60)
if minutes == 0 and secs == 0:
return "--/--"
return f"{minutes:02d}:{secs:02d}"
def date_format(value: Any, fmt: str = "Y-M-D h:m:s") -> str:
"""时间对象/时间戳/字符串 → 日期字符串(Y/M/D/h/m/s 占位)。"""
if not value:
return ""
if isinstance(value, datetime):
date = value
elif isinstance(value, (int, float)):
date = datetime.fromtimestamp(value / 1000 if value > 1e11 else value)
elif isinstance(value, str):
raw = value
if "T" not in raw:
raw = raw.split(".")[0].replace("-", "/")
parsed: datetime | None = None
for pattern in ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
try:
parsed = datetime.strptime(raw[: len(pattern) + 10], pattern)
break
except ValueError:
continue
if parsed is None:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return ""
date = parsed
else:
return ""
return (
fmt.replace("Y", str(date.year))
.replace("M", f"{date.month:02d}")
.replace("D", f"{date.day:02d}")
.replace("h", f"{date.hour:02d}")
.replace("m", f"{date.minute:02d}")
.replace("s", f"{date.second:02d}")
)
def date_format2(timestamp_ms: float | int) -> str:
"""相对时间格式化(用于评论时间)。"""
differ = int((time.time() * 1000 - float(timestamp_ms)) / 1000)
if differ < 60:
return f"{differ}秒前"
if differ < 3600:
return f"{differ // 60}分钟前"
if differ < 86400:
return f"{differ // 3600}小时前"
if differ < 2592000:
return f"{differ // 86400}天前"
return date_format(timestamp_ms)
def format_play_count(num: float | int | None) -> str:
if num is None:
return "0"
if num > 100000000:
return f"{int(num / 10000000) / 10}亿"
if num > 10000:
return f"{int(num / 1000) / 10}万"
return str(num)
_ENTITY_MAP = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
"&nbsp;": " ",
}
def decode_name(text: Any = "") -> str:
"""解码 HTML 实体。"""
if not text:
return ""
if not isinstance(text, str):
text = str(text)
for entity, char in _ENTITY_MAP.items():
text = text.replace(entity, char)
return re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text)
def format_singer_name(
singers: Iterable[Mapping[str, Any]] | str | None,
name_key: str = "name",
join: str = "、",
) -> str:
"""歌手数组或字符串 → 展示名(数组按 name_key 提取后 join)。"""
if isinstance(singers, (list, tuple)):
names = [str(item[name_key]) for item in singers if isinstance(item, Mapping) and item.get(name_key)]
return decode_name(join.join(names))
return decode_name(str(singers or ""))
def pick(data: Any, *keys: str, default: Any = None) -> Any:
"""沿键路径取值(dict 缺失返回 default),迁移时统一空值访问。"""
cur = data
for key in keys:
if isinstance(cur, Mapping):
cur = cur.get(key)
else:
return default
if cur is None:
return default
return cur
+4
View File
@@ -0,0 +1,4 @@
"""平台包集合。每个子包导出 `MODULE: Platform`。
目录名与平台 id 的差异:5sing → fivesing(Python 标识符不能以数字开头)。
"""
+110
View File
@@ -0,0 +1,110 @@
"""平台模块契约 —— 各平台包导出 `MODULE = Platform(...)`。
对应原 TS 的 `sdk/<platform>/index.ts` default export 对象:
const kw = { musicSearch, leaderboard, songList, hotSearch, comment, album, playlist, parse,
getMusicUrl, getLyric, getPic, handleMusicInfo, getMusicInfo, ... }
Python 侧用 `Platform` 容器承载子模块(属性名 snake_case),能力位缺省为 None 表示不支持,
路由层据此返回 404(与 /sources 的能力位同源)。
子模块协议:
music_search.search(keyword, page=1, limit=20, retry_num=0) -> {"list": [...], ...}
leaderboard.get_boards() -> {"list": [{"id", "name", "bangid"}]}
leaderboard.get_list(bangid, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.get_list(sort_id, tag_id, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.sort_list -> [{"id", "name"}](可选,路由取首项作默认排序)
hot_search.get_list() -> {"list": [str], "source"}
singer.get_info(id) / get_song_list(id, page, limit) / get_album_list(id, page, limit)
album.get_album_detail(id, page, limit) | get_album_list_detail(id, page)
album.search_album(keyword, page, limit)
playlist.get_playlist_detail(id, page, limit) | get_playlist_songs(id)
playlist.search_playlist(keyword, page, limit) | get_categories() | get_category_playlists(id, page, limit)
parse.parse_song(link) / parse_album(link) / parse_playlist(link)
user_playlist.get_list(page, limit)
顶层方法(由 Platform 直接持有):
get_music_url(song_info, quality, retry_num=0) -> {"type", "url"}
get_lyric(song_info) -> {"lyric", "tlyric", "rlyric"?, "yrc"?}
get_pic(song_info) -> str
get_music_info(song_info) -> dict(可选,酷我兜底等场景需要)
handle_music_info(song_info) -> dict(可选,补全歌名/歌手/封面)
get_music_detail_page_url(song_info) -> str(可选)
"""
from __future__ import annotations
from typing import Any
# 能力位名称(/sources 的 capabilities 键 → Platform 属性)
CAPABILITY_ATTRS = {
"search": "music_search",
"url": "get_music_url",
"lyric": "get_lyric",
"pic": "get_pic",
"boards": "leaderboard",
"hotSearch": "hot_search",
"songList": "song_list",
"singer": "singer",
"playlist": "playlist",
"parse": "parse",
"userPlaylist": "user_playlist",
"comment": "comment",
}
class Platform:
"""平台模块容器。只声明属性,不做校验 —— 缺省即不支持。"""
# 平台短码与中文名(/sources 用)
id: str = ""
name: str = ""
music_search: Any = None
leaderboard: Any = None
song_list: Any = None
hot_search: Any = None
comment: Any = None
singer: Any = None
album: Any = None
playlist: Any = None
parse: Any = None
user_playlist: Any = None
get_music_url: Any = None
get_lyric: Any = None
get_pic: Any = None
get_music_info: Any = None
handle_music_info: Any = None
get_music_urls: Any = None
get_music_detail_page_url: Any = None
init: Any = None
def __init__(self, **kwargs: Any) -> None:
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self) -> str:
caps = [key for key in CAPABILITY_ATTRS.values() if getattr(self, key, None) is not None]
return f"<Platform {self.id} caps={','.join(caps)}>"
def capability_map(platform: Platform) -> dict[str, bool]:
"""生成 /sources 的 capabilities 字段(键名沿用原 API 的驼峰写法)。"""
caps: dict[str, bool] = {name: getattr(platform, attr, None) is not None for name, attr in CAPABILITY_ATTRS.items()}
album = platform.album
caps["album"] = album is not None and (
getattr(album, "get_album_detail", None) is not None
or getattr(album, "get_album_list_detail", None) is not None
)
caps["albumSearch"] = album is not None and getattr(album, "search_album", None) is not None
playlist = platform.playlist
caps["playlist"] = playlist is not None and getattr(playlist, "get_playlist_detail", None) is not None
caps["playlistSearch"] = playlist is not None and getattr(playlist, "search_playlist", None) is not None
caps["playlistCategories"] = playlist is not None and getattr(playlist, "get_categories", None) is not None
parse = platform.parse
caps["parse"] = parse is not None and any(
getattr(parse, f"parse_{kind}", None) is not None for kind in ("song", "album", "playlist")
)
caps.pop("comment", None)
return caps
+134
View File
@@ -0,0 +1,134 @@
"""Apple Music 平台模块(对应 sdk/apple/index.ts,上游 music-lib/apple/apple.go 的 Node/TS 移植)。
能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到)/ 封面 /
专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 apis/apple.py)。
凭据:auth_store 的 apple 记录,cookie 形如
``"media-user-token=xxx;storefront=cn;token=xxx"``,也支持 storefront/mediaUserToken/token 直填字段。
未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.apple import album, lyric, music_search, parse, playlist
from app.sdk.platforms.apple.util import (
apple_artwork_url,
apple_song_from_catalog,
apple_web_url,
extract_apple_id,
fetch_apple_song_resource,
get_apple_config,
get_apple_token,
)
def _song_id_of(song_info: dict[str, Any]) -> str:
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
async def _init() -> bool:
"""预热 developer token(失败不阻塞启动,路由调用时会重试)。"""
try:
await get_apple_token()
return True
except Exception: # noqa: BLE001 - token 预热失败不影响后续请求的重试
return False
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.apple import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort)。"""
if song_info.get("img"):
return str(song_info["img"])
song_id = _song_id_of(song_info)
if not song_id:
return None
try:
res = await fetch_apple_song_resource(song_id)
if not res:
return None
return apple_artwork_url(res.get("attributes", {}).get("artwork"), 600) or None
except Exception: # noqa: BLE001 - 封面取不到返回 None
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""歌曲详情(best-effort;等价上游 Parse(id))。"""
song_id = _song_id_of(song_info)
if not song_id:
return None
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
return apple_song_from_catalog(res) if res else None
class _AppleAlbum:
"""契约 album 能力位(含 TS index.ts 里挂的 parseAlbumLink)。"""
get_album_detail = staticmethod(album.get_album_detail)
get_album_songs = staticmethod(album.get_album_songs)
search_album = staticmethod(album.search_album)
parse_album_link = staticmethod(album.parse_album_link)
class _AppleSongList:
"""契约别名 songList:Apple 的歌单分类即 genre curators,供 /song-list/apple 复用。"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "热门", "id": "1526756058"}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, limit: int = 20) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate, page, limit)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
song_id = _song_id_of(song_info)
if not song_id:
return "https://music.apple.com/"
return apple_web_url("song", song_id, get_apple_config()["storefront"])
def status() -> dict[str, Any]:
"""登录状态(media-user-token 存在性判断;无在线校验接口)。"""
cred = get_credentials("apple") or {}
cfg = get_apple_config()
if cfg["mediaUserToken"] or cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="apple",
name="Apple Music",
init=_init,
music_search=music_search,
album=_AppleAlbum,
playlist=playlist,
song_list=_AppleSongList,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+89
View File
@@ -0,0 +1,89 @@
"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。
详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
→ data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_album
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
async def get_album_detail(raw_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(page/limit 在完整曲目上切片,与其它平台 get_album_detail 契约一致)。"""
album_id = extract_apple_id(str(raw_id or ""), "album")
if not album_id:
raise RuntimeError(f"invalid apple music album id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/albums/{album_id}", {"extend": "extendedAssetUrls"})
)
data = as_list(body.get("data"))
album = as_dict(data[0]) if data else {}
if not album:
raise RuntimeError(f"apple album not found: {album_id}")
tracks = await fetch_all_track_resources(as_dict(album.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track, album_id) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 50))
summary = apple_album_to_playlist(album)
# 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": album_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
"releaseDate": summary["extra"]["release_date"],
"recordLabel": summary["extra"]["record_label"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只返回曲目数组。"""
detail = await get_album_detail(album_id, 1, 100000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页)。"""
detail = await get_album_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = ["get_album_detail", "get_album_songs", "parse_album_link", "search_album"]
+45
View File
@@ -0,0 +1,45 @@
"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 apple.go GetLyrics)。
通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
→ data[0].relationships.lyrics.data[*].attributes.ttml
上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
(即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词需要 media-user-token(订阅态);
本实现保持上游行为:拿不到就返回 ``{lyric: '', tlyric: ''}``,不抛错。
"""
from __future__ import annotations
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
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
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 无效")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"include": "lyrics", "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
first = as_dict(data[0]) if data else {}
relationships = as_dict(first.get("relationships"))
lyrics = as_list(as_dict(relationships.get("lyrics")).get("data"))
for rel in lyrics:
text = str(as_dict(as_dict(rel).get("attributes")).get("ttml") or "")
if text:
return {"lyric": text, "tlyric": ""}
return {"lyric": "", "tlyric": ""}
async def get_lyrics_text(song_id: str) -> str:
"""非取消句柄版:直接返回歌词文本(内部/测试用)。"""
result = await get_lyric({"songmid": song_id})
return result.get("lyric") or ""
+90
View File
@@ -0,0 +1,90 @@
"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。
通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.py)
单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
get_apple_config,
)
#: amp-api search 单页硬上限
SEARCH_PAGE_MAX = 25
async def _catalog_search(keyword: str, types: str, page: int, limit: int) -> dict[str, Any]:
word = str(keyword or "").strip()
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 100)
if not word:
return {"body": None, "page": p, "limit": size}
cfg = get_apple_config()
body = await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/search",
{
"term": word,
"types": types,
"limit": min(size, SEARCH_PAGE_MAX),
"offset": (p - 1) * size,
},
)
return {"body": body, "page": p, "limit": size}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,amp-api 无重试语义
result = await _catalog_search(keyword, "songs", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
bucket = as_dict(results.get("songs"))
songs = [apple_song_from_catalog(item) for item in as_list(bucket.get("data"))]
has_next = bool(bucket.get("next"))
return {
"list": songs,
"allPage": p + 1 if has_next else p,
"total": (p - 1) * size + len(songs),
"limit": size,
"source": "apple",
}
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchAlbum:搜索专辑(返回歌单摘要形状)。"""
result = await _catalog_search(keyword, "albums", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("albums")).get("data"))
songs = [apple_album_to_playlist(item) for item in items]
return {
"list": songs,
"total": (p - 1) * size + len(songs),
"page": p,
"limit": size,
"source": "apple",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist:搜索歌单。"""
result = await _catalog_search(keyword, "playlists", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("playlists")).get("data"))
playlists = [apple_playlist_to_playlist(item) for item in items]
return {
"list": playlists,
"total": (p - 1) * size + len(playlists),
"page": p,
"limit": size,
"source": "apple",
}
+34
View File
@@ -0,0 +1,34 @@
"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 apple.go Parse / ParseAlbum / ParsePlaylist)。
song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.apple.album import parse_album_link
from app.sdk.platforms.apple.playlist import parse_playlist_link
from app.sdk.platforms.apple.util import apple_song_from_catalog, extract_apple_id, fetch_apple_song_resource
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链)。"""
song_id = extract_apple_id(str(link or ""), "song")
if not song_id:
raise RuntimeError(f"invalid apple music song link: {link}")
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
if not res:
raise RuntimeError(f"apple song not found: {song_id}")
return apple_song_from_catalog(res)
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+177
View File
@@ -0,0 +1,177 @@
"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。
详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
曲目超过一页时沿 relationships.tracks.next 全量拉取(带 20 页上限防死循环)。
分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
链接:extract_apple_id(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_playlist
from app.sdk.platforms.apple.util import (
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
#: 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators)
APPLE_CURATOR_CATEGORIES: list[dict[str, str]] = [
{"id": "1526756058", "name": "热门"},
{"id": "1479949880", "name": "C-Pop"},
{"id": "1019400042", "name": "国语流行"},
{"id": "1019398918", "name": "粤语流行"},
{"id": "1019399540", "name": "国际流行"},
{"id": "1019399551", "name": "K-Pop"},
{"id": "1019399547", "name": "J-Pop"},
{"id": "989061415", "name": "嘻哈/说唱"},
{"id": "1019400044", "name": "R&B"},
{"id": "1019400046", "name": "摇滚"},
{"id": "1019397973", "name": "另类音乐"},
{"id": "976439535", "name": "舞曲"},
{"id": "976439536", "name": "电子"},
{"id": "976439541", "name": "独立音乐"},
{"id": "1019399549", "name": "爵士乐"},
{"id": "1019398924", "name": "古典音乐"},
{"id": "976439528", "name": "蓝调"},
{"id": "976439534", "name": "乡村音乐"},
{"id": "1531542847", "name": "拉丁音乐"},
{"id": "988656348", "name": "非洲音乐"},
{"id": "976439543", "name": "金属乐"},
{"id": "976439550", "name": "朋克乐"},
{"id": "1019400049", "name": "不插电"},
{"id": "1231181168", "name": "影视原声"},
{"id": "1441811365", "name": "DJ 混音精选"},
{"id": "1532467784", "name": "瞩目之星"},
{"id": "1554938339", "name": "年代之声"},
{"id": "1564180390", "name": "空间音频"},
{"id": "989010186", "name": "亲子"},
]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:静态返回 Apple Music genre curators。"""
return {
"source": "apple",
"list": [
{"id": item["id"], "name": item["name"], "group": "Apple Music 分类", "source": "apple"}
for item in APPLE_CURATOR_CATEGORIES
],
}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐)。"""
cate = str(category_id or "").strip()
if not cate:
raise RuntimeError("apple 分类 id 不能为空")
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 300)
api_max = 25
offset = (p - 1) * size
all_playlists: list[dict[str, Any]] = []
while len(all_playlists) < size:
batch_size = min(api_max, size - len(all_playlists))
try:
body = as_dict(
await apple_amp_get(
f"/v1/catalog/cn/apple-curators/{cate}/playlists",
{"limit": batch_size, "offset": offset, "l": "zh-Hans-CN"},
)
)
except Exception: # 上游:已有数据时返回已取部分
if all_playlists:
break
raise
data = as_list(body.get("data"))
all_playlists.extend(apple_playlist_to_playlist(item) for item in data)
# 没有更多数据或没有下一页
if len(data) < batch_size or not body.get("next"):
break
offset += len(data)
return {"source": "apple", "list": all_playlists[:size], "page": p, "limit": size, "total": len(all_playlists)}
async def get_playlist_detail(raw_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(全量曲目后按 page/limit 切片)。"""
playlist_id = extract_apple_id(str(raw_id or ""), "playlist")
if not playlist_id:
raise RuntimeError(f"invalid apple music playlist id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/playlists/{playlist_id}",
{"limit[tracks]": 300, "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
item = as_dict(data[0]) if data else {}
if not item:
raise RuntimeError(f"apple playlist not found: {playlist_id}")
tracks = await fetch_all_track_resources(as_dict(item.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 100))
summary = apple_playlist_to_playlist(item)
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": playlist_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只返回曲目数组。"""
detail = await get_playlist_detail(playlist_id, 1, 100000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页)。"""
detail = await get_playlist_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = [
"APPLE_CURATOR_CATEGORIES",
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"parse_playlist_link",
"search_playlist",
]
+465
View File
@@ -0,0 +1,465 @@
"""Apple Music 公共工具(对应 sdk/apple/util.ts,上游 music-lib/apple/apple.go 的移植基础层)。
* ``New(cookie)`` → auth_store 的 apple 记录:cookie 支持
``"media-user-token=xxx;storefront=cn;token=xxx"`` 形态。
* ``fetch_apple_token``:从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)。
当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi 开头而非上游正则写的 eyJh,
故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取。
* amp 请求(httpFetch 不自动跟 302,首页跳转用 fetch_follow 手动跟)。
* 模型转换与 ID 提取(字段映射同上游,apple_test.go 的断言逐条保留)。
"""
from __future__ import annotations
import asyncio
import base64
import json
import re
import time
from typing import Any
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
APPLE_HOMEPAGE_URL = "https://music.apple.com"
APPLE_AMP_API_URL = "https://amp-api.music.apple.com"
DEFAULT_STOREFRONT = "us"
APPLE_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
#: JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
TOKEN_CACHE_TTL_MS = 12 * 60 * 60 * 1000
_cached_token = ""
_cached_at = 0.0
#: 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
_bypass_explicit_token = False
_token_lock = asyncio.Lock()
def parse_cookie_parts(cookie: str) -> dict[str, str]:
"""上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白。"""
result: dict[str, str] = {}
for part in str(cookie or "").split(";"):
trimmed = part.strip()
idx = trimmed.find("=")
if idx > 0:
result[trimmed[:idx].strip()] = trimmed[idx + 1 :].strip()
return result
def get_apple_config() -> dict[str, str]:
"""读取 apple 平台运行时配置(对应上游 New(cookie))。"""
cred = get_credentials("apple") or {}
parts = parse_cookie_parts(str(cred.get("cookie") or ""))
storefront = str(cred.get("storefront") or parts.get("storefront") or DEFAULT_STOREFRONT).strip()
return {
"storefront": storefront or DEFAULT_STOREFRONT,
"mediaUserToken": str(cred.get("mediaUserToken") or parts.get("media-user-token") or "").strip(),
"token": str(cred.get("token") or parts.get("token") or "").strip(),
}
def decode_apple_token_payload(token: str) -> dict[str, Any]:
"""解码 JWT payload(Apple token 为 ES256 JWT)。"""
try:
part = str(token or "").split(".")[1]
padded = part.replace("-", "+").replace("_", "/")
padded += "=" * (-len(padded) % 4)
data = json.loads(base64.b64decode(padded).decode("utf-8"))
return data if isinstance(data, dict) else {}
except (IndexError, ValueError, TypeError):
return {}
def is_apple_token_expired(token: str) -> bool:
try:
exp = float(decode_apple_token_payload(token).get("exp") or 0)
except (TypeError, ValueError):
exp = 0
return exp > 0 and exp * 1000 < time.time() * 1000 + 60 * 1000
def extract_apple_tokens(js: str) -> list[str]:
"""从 index JS 中提取所有 JWT 形态的候选 token(含引号前缀的主匹配 + 无前缀回退)。"""
pattern = r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
found: list[str] = []
found.extend(m.group(1) for m in re.finditer(rf"""(?:=["']){pattern}""", str(js or "")))
if not found:
found.extend(m.group(1) for m in re.finditer(pattern, str(js or "")))
# 去重且保持顺序
return list(dict.fromkeys(found))
def pick_apple_token(js: str) -> str:
"""选择可用 token:优先 issuer=AMPWebPlay,其次第一个未过期;全部过期视为抓取失败。"""
tokens = extract_apple_tokens(js)
if not tokens:
return ""
alive = [token for token in tokens if not is_apple_token_expired(token)]
if not alive:
return ""
preferred = next(
(token for token in alive if str(decode_apple_token_payload(token).get("iss") or "") == "AMPWebPlay"), None
)
return preferred or alive[0] or ""
async def fetch_follow(url: str, headers: dict[str, str] | None = None, max_hops: int = 5) -> HttpResponse:
"""手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳)。"""
current = str(url)
response = None
for _hop in range(max_hops):
response = await http_fetch(current, headers=headers or {}, timeout=20_000)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
current = urljoin(current, location)
continue
return response
raise RuntimeError("apple: too many redirects")
def _response_text(response: HttpResponse) -> str:
if isinstance(response.body, str):
return response.body
if response.raw:
return response.raw.decode("utf-8", errors="replace")
return str(response.body or "")
async def fetch_apple_token() -> str:
"""上游 fetchAppleToken:首页 → index-legacy JS → bearer token。"""
home = await fetch_follow(APPLE_HOMEPAGE_URL, {"User-Agent": APPLE_UA})
if home.status_code != 200:
raise RuntimeError(f"apple: fetch homepage error: HTTP {home.status_code}")
html = _response_text(home)
# 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
match = re.search(r"/(assets/index-legacy[~-][^/\"']+\.js)", html)
if not match:
match = re.search(r"/(assets/index[~-][^/\"']+\.js)", html)
if not match:
raise RuntimeError("apple: index.js URI not found")
js_res = await fetch_follow(f"{APPLE_HOMEPAGE_URL}/{match.group(1)}", {"User-Agent": APPLE_UA})
if js_res.status_code != 200:
raise RuntimeError(f"apple: fetch index.js error: HTTP {js_res.status_code}")
token = pick_apple_token(_response_text(js_res))
if not token:
raise RuntimeError("apple: token not found in index.js")
return token
async def get_apple_token(force: bool = False) -> str:
"""获取 developer token(缓存 + JWT exp 校验 + 并发去重)。"""
global _cached_token, _cached_at
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
async with _token_lock:
# 双检:并发调用者拿到锁后直接复用前一次抓取结果
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
token = await fetch_apple_token()
_cached_token = token
_cached_at = time.time() * 1000
return token
def invalidate_apple_token() -> None:
"""清除缓存 token(401/403 时调用,下次请求会回源重取)。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = True
def reset_apple_token_state() -> None:
"""仅测试/调试用:重置内存态。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = False
def apple_amp_headers(token: str) -> dict[str, str]:
cfg = get_apple_config()
headers = {
"Authorization": f"Bearer {token}",
"Origin": APPLE_HOMEPAGE_URL,
"Referer": f"{APPLE_HOMEPAGE_URL}/",
"User-Agent": APPLE_UA,
"Accept": "application/json",
}
# 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
if cfg["mediaUserToken"]:
headers["Cookie"] = f"media-user-token={cfg['mediaUserToken']}"
return headers
def build_apple_amp_url(uri: str, params: dict[str, Any] | None = None) -> str:
"""拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL。"""
raw = str(uri or "")
if re.match(r"^https?://", raw, re.IGNORECASE):
base = raw
elif raw.startswith("/"):
base = APPLE_AMP_API_URL + raw
else:
base = f"{APPLE_AMP_API_URL}/{raw}"
pairs = [(key, str(value)) for key, value in (params or {}).items() if value is not None and value != ""]
if not pairs:
return base
query = urlencode(pairs)
return base + ("&" if "?" in base else "?") + query
def _amp_error_detail(response: HttpResponse) -> str:
body = response.body if isinstance(response.body, dict) else {}
errors = as_list(body.get("errors"))
first = as_dict(errors[0]) if errors else {}
return str(first.get("detail") or first.get("title") or "")
async def apple_amp_get_tracked(uri: str, params: dict[str, Any] | None = None) -> Any:
"""amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)。"""
url = build_apple_amp_url(uri, params)
async def attempt() -> HttpResponse:
token = await get_apple_token()
return await http_fetch(url, headers=apple_amp_headers(token), timeout=20_000)
response = await attempt()
if response.status_code in (401, 403):
invalidate_apple_token()
response = await attempt()
if not (200 <= response.status_code < 300):
detail = _amp_error_detail(response)
raise RuntimeError(f"apple amp-api HTTP {response.status_code}{f': {detail}' if detail else ''}")
return response.body
async def apple_amp_get(uri: str, params: dict[str, Any] | None = None) -> Any:
return await apple_amp_get_tracked(uri, params)
#: 拉取 relationships.tracks 全部分页时的页数上限(防死循环)
MAX_TRACK_PAGES = 20
async def fetch_all_track_resources(tracks: Any) -> list[Any]:
"""拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources)。"""
tracks = as_dict(tracks)
all_tracks: list[Any] = list(as_list(tracks.get("data")))
next_uri = str(tracks.get("next") or "").strip()
page = 0
while next_uri and page < MAX_TRACK_PAGES:
body = as_dict(await apple_amp_get_tracked(next_uri))
all_tracks.extend(as_list(body.get("data")))
next_uri = str(body.get("next") or "").strip()
page += 1
return all_tracks
def apple_artwork_url(artwork: Any, size: int = 600) -> str:
"""上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位。"""
artwork = as_dict(artwork)
url = str(artwork.get("url") or "")
if not url:
return ""
return url.replace("{w}", str(size)).replace("{h}", str(size))
def _round_number(value: Any) -> int:
"""JS Math.round 的等价实现(.5 向上取整)。"""
import math
try:
number = float(value or 0)
except (TypeError, ValueError):
number = 0.0
return math.floor(number + 0.5)
def apple_song_from_catalog(res: Any, default_album_id: str = "") -> dict[str, Any]:
"""上游 appleSongFromCatalog → 契约 Song 形状。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
previews = as_list(attr.get("previews"))
preview_url = str(as_dict(previews[0]).get("url") or "") if previews else ""
duration_sec = _round_number(float(attr.get("durationInMillis") or 0) / 1000)
return {
"songmid": str(res.get("id") or ""),
"name": attr.get("name") or "",
"singer": attr.get("artistName") or "",
"albumId": str(attr.get("albumId") or default_album_id or ""),
"albumName": attr.get("albumName") or "",
"interval": format_play_time(duration_sec),
"img": apple_artwork_url(attr.get("artwork"), 600) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "apple",
"link": attr.get("url") or "", # music.apple.com 网页地址
"previewUrl": preview_url, # 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
"duration": duration_sec,
"isrc": attr.get("isrc") or "",
}
def apple_album_to_playlist(res: Any) -> dict[str, Any]:
"""上游 appleAlbumToPlaylist → 契约歌单/专辑摘要。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
notes = as_dict(attr.get("editorialNotes"))
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("artistName") or "",
"description": notes.get("short") or "",
"link": attr.get("url") or "",
"source": "apple",
"extra": {
"release_date": attr.get("releaseDate") or "",
"record_label": attr.get("recordLabel") or "",
},
}
def apple_playlist_to_playlist(res: Any) -> dict[str, Any]:
"""上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游)。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
description = as_dict(attr.get("description"))
notes = as_dict(attr.get("editorialNotes"))
desc = description.get("short") or description.get("standard") or notes.get("short") or notes.get("standard") or ""
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("curatorName") or "",
"description": desc,
"link": attr.get("url") or "",
"source": "apple",
}
def apple_web_url(kind: str, media_id: str, storefront: str = DEFAULT_STOREFRONT) -> str:
sf = str(storefront or DEFAULT_STOREFRONT)
return f"{APPLE_HOMEPAGE_URL}/{sf}/{kind}/{media_id}"
def is_apple_raw_id(value: str, media_type: str) -> bool:
"""上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字)。"""
if re.search(r"[/?#]", value) or "://" in value:
return False
if media_type == "playlist":
if value.startswith("pl."):
return re.fullmatch(r"[a-zA-Z0-9._-]+", value) is not None
return re.fullmatch(r"[a-zA-Z0-9_-]+", value) is not None
return re.fullmatch(r"\d+", value) is not None
def extract_apple_id_from_path(path: str, media_type: str) -> str:
"""上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id>。"""
trimmed = re.sub(r"^/+|/+$", "", str(path or ""))
if not trimmed:
return ""
segments = trimmed.split("/")
for index, segment in enumerate(segments):
if segment != media_type and segment != f"{media_type}s":
continue
if index + 2 < len(segments):
return segments[index + 2]
if index + 1 < len(segments):
return segments[index + 1]
return ""
def extract_apple_id(link: str, media_type: str) -> str:
"""上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底。"""
raw = str(link or "").strip()
if not raw:
return ""
if is_apple_raw_id(raw, media_type):
return raw
path = ""
query = ""
try:
parts = urlsplit(raw)
if parts.scheme or parts.netloc:
path = parts.path or ""
query = f"?{parts.query}" if parts.query else ""
except ValueError:
path, query = "", ""
if not path:
idx = raw.find("?")
path = raw[:idx] if idx >= 0 else raw
query = raw[idx:] if idx >= 0 else ""
if path:
if media_type == "song":
values = parse_qs(query.lstrip("?"), keep_blank_values=True).get("i")
song_id = values[0].strip() if values else ""
if song_id:
return song_id
from_path = extract_apple_id_from_path(path, media_type)
if from_path:
return from_path
patterns = [
rf"{media_type}/[^/]+/(\d+)",
rf"{media_type}s?/[^/]+/[^/]+/(\d+)",
r"/(\d+)$",
r"[?&]i=(\d+)",
]
if media_type == "playlist":
patterns.extend(
[
r"playlists?/[^/?#]+/([a-zA-Z0-9._-]+)",
r"playlists?/([a-zA-Z0-9._-]+)(?:[?#]|$)",
]
)
for pattern in patterns:
match = re.search(pattern, raw)
if match:
return match.group(1)
return ""
async def fetch_apple_song_resource(song_id: str, include: str = "") -> Any:
"""歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>)。"""
cfg = get_apple_config()
params: dict[str, Any] = {"extend": "extendedAssetUrls"}
if include:
params["include"] = include
body = as_dict(await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/songs/{song_id}", params))
data = as_list(body.get("data"))
return data[0] if data else None
def quote_path_segment(value: str) -> str:
"""路径段转义(ID 基本是字母数字,保留以免未来出现特殊字符)。"""
return quote(str(value), safe="._-")
+96
View File
@@ -0,0 +1,96 @@
"""千千音乐(music.91q.com)平台模块(对应 sdk/bd/index.ts)。
上游 music-lib/qianqian 的移植。匿名可用面(实测):
* 搜索(type=1)、专辑搜索 + 详情(type=3、album/info);
* 歌单详情 + 分类 + 分类歌单;
* 歌词(song/info 返回 txt URL);
* 免费曲目 128k/320k/flac 明文直链(tracklink 签名通道)。
限制(与上游一致,不造假数据):
* VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 `payPlay`;
* 歌单关键词搜索(`/v1/search?type=6`)服务端已下线(errno 23001),`search_playlist` 返回空列表;
* 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 `user_playlist`;
* 千千无独立封面接口:搜索结果/专辑/歌单自带 pic,缺失时回退 `song/info`;
* 千千无「榜单」概念:`song_list` 以歌单分类作为标签体系(tagId 为 `/v1/tracklist/category` 的 subCate id)。
凭据:cookie 从 `app/sdk/auth_store.py` 的 `get_credentials('bd')` 读取(可选,仅影响 VIP 等登录态资源)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bd import album, lyric, music_info, music_search, parse, playlist
from app.sdk.platforms.bd.util import get_qian_cookie
class _SongList:
"""MusicServer 的 songList 契约:`get_list(sort_id, tag_id, page)` / `get_tags()`。
千千把歌单分类当标签体系,`tagId` 为 subCate id(纯数字才下发,其它回落到「全部」)。
"""
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate if re.fullmatch(r"\d+", cate) else "", page, 30)
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
song_list = _SongList()
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""千千无独立封面接口:先用曲目自带的 img,缺失时回退 song/info。"""
img = song_info.get("img")
if img:
return str(img)
try:
info = await music_info.get_song_info(str(song_info.get("songmid") or ""))
return info.get("img") or None
except Exception: # noqa: BLE001 - 与原文一致:封面取不到就返回 null,不影响调用方
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
return await music_info.get_song_info(str(song_info.get("songmid") or ""))
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
tsid = str(song_info.get("songmid") or "").strip()
return f"https://music.91q.com/song/{tsid}" if tsid else "https://music.91q.com/"
def status() -> dict[str, Any]:
"""凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流)。"""
return {"logged": True} if get_qian_cookie() else {"logged": False}
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:`apis/bd.py` 反过来依赖本包的 util,顶层导入会形成环。"""
from app.sdk.apis.bd import get_music_url
return await get_music_url(song_info, quality, retry_num)
MODULE = Platform(
id="bd",
name="千千音乐",
music_search=music_search,
song_list=song_list,
album=album,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=lyric.get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+203
View File
@@ -0,0 +1,203 @@
"""千千音乐专辑(对应 sdk/bd/album.ts)。
搜索:`/v1/search?type=3`(特殊字符触发 errno 23001 时用清洗后的关键词重试)。
详情:`/v1/album/info?albumAssetCode=P...`(P 码可直接用;纯数字旧 id 走
`/v1/album/albumid2psid` 转换)。
专辑链接:`https://music.91q.com/album/<albumAssetCode>`。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.bd.util import (
APP_ID,
album_link,
api_get,
api_ok,
error_text,
join_artists,
map_track,
normalize_album_asset_code,
release_date10,
sanitize_album_keyword,
to_number,
)
class _RetryableAlbumError(RuntimeError):
"""专辑搜索失败;`retry_with_sanitized` 表示 23001(词非法)可换清洗后的词重试。"""
def __init__(self, message: str, retry_with_sanitized: bool) -> None:
super().__init__(message)
self.retry_with_sanitized = retry_with_sanitized
def _map_album_summary(item: Any) -> dict[str, Any]:
source = item if isinstance(item, dict) else {}
id = normalize_album_asset_code(source.get("albumAssetCode"))
track_list = source.get("trackList")
return {
"id": id,
"name": str(source.get("title") or "").strip(),
"cover": str(source.get("pic") or "").strip(),
"trackCount": len(track_list) if isinstance(track_list, list) else 0,
"creator": join_artists(source.get("artist")),
"description": str(source.get("introduce") or "").strip(),
"releaseDate": release_date10(source.get("releaseDate")),
"genre": str(source.get("genre") or "").strip(),
"lang": str(source.get("lang") or "").strip(),
"link": album_link(id) if id else "",
"source": "bd",
}
async def _search_album_once(word: str, page: int = 1, limit: int = 10) -> list[Any]:
body = await api_get(
"/v1/search",
{
"word": word,
"type": "3",
"pageNo": str(max(1, page)),
"pageSize": str(max(1, limit)),
"appid": APP_ID,
},
)
if not api_ok(body):
errno = body.get("errno") if isinstance(body, dict) else None
raise _RetryableAlbumError(error_text(body, "千千专辑搜索失败"), to_number(errno) == 23001)
data = (body.get("data") if isinstance(body, dict) else None) or {}
items = data.get("typeAlbum") if isinstance(data, dict) else None
return list(items) if isinstance(items, list) else []
async def search_album_items(keyword: str, page: int = 1, limit: int = 10) -> list[Any]:
"""上游 searchAlbumItems:原词失败(23001)时用清洗后的关键词重试一次。"""
words = [str(keyword if keyword is not None else "").strip()]
sanitized = sanitize_album_keyword(keyword)
if sanitized and sanitized != words[0]:
words.append(sanitized)
last_err: Exception | None = None
for word in words:
try:
return await _search_album_once(word, page, limit)
except _RetryableAlbumError as err:
last_err = err
# 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
if not err.retry_with_sanitized:
break
if last_err is not None:
raise last_err
return []
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""搜索专辑(返回歌单/专辑统一摘要形状)。"""
word = str(keyword if keyword is not None else "").strip()
if not word:
return {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
items = await search_album_items(word, page, limit)
list_out = [album for album in (_map_album_summary(item) for item in items) if album["id"]][:limit]
return {"source": "bd", "list": list_out, "total": len(items), "page": page, "limit": limit}
async def resolve_album_asset_code(id: str) -> str:
"""旧数字专辑 id → PSID(P 码);已是 P 码则原样返回。"""
raw = str(id if id is not None else "").strip()
if not raw:
raise RuntimeError("千千专辑 id 为空")
normalized = normalize_album_asset_code(raw)
if normalized:
return normalized
body = await api_get("/v1/album/albumid2psid", {"albumid": raw, "appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千专辑 id 转换失败"))
data = body.get("data") if isinstance(body, dict) else None
for item in data if isinstance(data, list) else []:
if isinstance(item, dict):
psid = normalize_album_asset_code(item.get("psid"))
if psid:
return psid
raise RuntimeError("千千专辑 asset code 未找到")
async def _fetch_album_info(id: str) -> tuple[str, dict[str, Any]]:
album_id = await resolve_album_asset_code(id)
body = await api_get("/v1/album/info", {"albumAssetCode": album_id, "appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千专辑详情获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
if not normalize_album_asset_code(data.get("albumAssetCode") if isinstance(data, dict) else None):
raise RuntimeError("千千专辑不存在或已下架")
return album_id, data
async def get_album_detail(id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""专辑详情(统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片)。"""
album_id, data = await _fetch_album_info(id)
title = str(data.get("title") or "").strip()
cover = str(data.get("pic") or "").strip()
creator = join_artists(data.get("artist"))
all_tracks = data.get("trackList")
tracks = list(all_tracks) if isinstance(all_tracks, list) else []
songs: list[dict[str, Any]] = []
for item in tracks:
song = map_track(item, album_id=album_id, album_name=title, img=cover, singer=creator)
if not song["songmid"]:
continue
# 专辑内曲目补上序号(上游 Extra.track / sort);为 0 时不写该键,与 TS 的 undefined 一致
sort = to_number(item.get("sort") if isinstance(item, dict) else None)
if sort > 0:
song["track"] = int(sort)
songs.append(song)
size = max(1, limit)
start = (max(1, page) - 1) * size
return {
"source": "bd",
"info": {
"id": album_id,
"name": title,
"img": cover,
"desc": str(data.get("introduce") or "").strip(),
"author": creator,
"releaseDate": release_date10(data.get("releaseDate")),
"genre": str(data.get("genre") or "").strip(),
"lang": str(data.get("lang") or "").strip(),
"total": len(songs),
},
"list": songs[start : start + size],
"page": page,
"limit": limit,
"total": len(songs),
"allPage": math.ceil(len(songs) / size) or 1,
}
async def get_album_songs(id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只取曲目列表。"""
detail = await get_album_detail(id, 1, 10000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态。"""
raw = str(link if link is not None else "").strip()
patterns = (
re.compile(r"music\.91q\.com/album/([A-Za-z0-9]+)"),
re.compile(r"albumAssetCode=([A-Za-z0-9]+)"),
re.compile(r"albumid=(\d+)"),
)
for pattern in patterns:
match = pattern.search(raw)
if match:
return await get_album_detail(match.group(1))
raise RuntimeError("无效的千千专辑链接")
+30
View File
@@ -0,0 +1,30 @@
"""千千音乐歌词(对应 sdk/bd/lyric.ts)。
链路:`/v1/song/info` → `data[0].lyric` 为歌词文本 URL(static-qianqian.taihe.com/*.txt)
→ 直接下载文本。返回 `{lyric, tlyric: ''}`(千千只有原文歌词,无翻译)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bd.music_info import get_song_info
from app.sdk.platforms.bd.util import request_headers
async def get_lyric(song_info: dict[str, Any]) -> dict[str, Any]:
tsid = str(song_info.get("songmid") or song_info.get("TSID") or "").strip()
if not tsid:
raise RuntimeError("千千歌词需要 songmid(TSID)")
info = await get_song_info(tsid)
lyric_url = str(info.get("lyricUrl") or "").strip()
if not lyric_url:
raise RuntimeError("千千该歌曲暂无歌词")
resp = await http_fetch(lyric_url, method="get", headers=request_headers(), timeout=15000)
if resp.status_code != 200:
raise RuntimeError(f"千千歌词下载失败(HTTP {resp.status_code})")
lyric = resp.body if isinstance(resp.body, str) else resp.raw.decode("utf-8", errors="replace")
return {"lyric": lyric, "tlyric": ""}
+34
View File
@@ -0,0 +1,34 @@
"""千千音乐单曲信息(对应 sdk/bd/musicInfo.ts)。
接口返回 `data[]`:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track
async def get_song_info(tsid: str) -> dict[str, Any]:
"""由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl)。"""
id = str(tsid if tsid is not None else "").strip()
if not id:
raise RuntimeError("千千歌曲 id(TSID)为空")
body = await api_get("/v1/song/info", {"TSID": id, "appid": APP_ID})
data = body.get("data") if isinstance(body, dict) else None
if not api_ok(body) and not isinstance(data, list):
raise RuntimeError(error_text(body, "千千歌曲信息获取失败"))
item = data[0] if isinstance(data, list) and data else None
if not isinstance(item, dict):
raise RuntimeError("千千歌曲不存在或已下架")
song = map_track(item)
return {
**song,
# 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词)
"songmid": song["songmid"] or id,
"lyricUrl": str(item.get("lyric") or "").strip(),
"raw": item,
}
+41
View File
@@ -0,0 +1,41 @@
"""千千音乐搜索(对应 sdk/bd/musicSearch.ts)。
`/v1/search?word=&type=1&pageNo=&pageSize=`,appid + 签名。
响应 `data.typeTrack[]`:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip。
注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay,
以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track, to_int, to_number
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
word = str(keyword if keyword is not None else "").strip()
if not word:
return {"list": [], "allPage": 1, "total": 0, "limit": limit, "source": "bd"}
body = await api_get(
"/v1/search",
{"word": word, "type": "1", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
)
# 23001 = 服务端不认该搜索类型/关键词,与原文一致:按空结果处理而不是报错
if not api_ok(body) and to_number(body.get("errno") if isinstance(body, dict) else None) != 23001:
raise RuntimeError(error_text(body, "千千搜索失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
tracks = data.get("typeTrack") if isinstance(data, dict) else None
raw = tracks if isinstance(tracks, list) else []
total = to_int(data.get("total") if isinstance(data, dict) else None, len(raw))
have_more = to_number(data.get("haveMore") if isinstance(data, dict) else None) == 1
songs = [song for song in (map_track(item) for item in raw) if song["songmid"]]
page_size = max(1, limit)
all_page = page + 1 if have_more else max(page, math.ceil(total / page_size) or 1)
return {"list": songs, "allPage": all_page, "total": total, "limit": limit, "source": "bd"}
+48
View File
@@ -0,0 +1,48 @@
"""千千音乐链接解析(对应 sdk/bd/parse.ts)。
歌曲:`music.91q.com/song/<TSID>`(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)。
专辑:`music.91q.com/album/<P码>` / `?albumAssetCode=` / `?albumid=`。
歌单:`music.91q.com/songlist|tracklist|playlist/<id>` / `?*id=` / 裸歌单 id。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bd import album as album_module
from app.sdk.platforms.bd import playlist as playlist_module
from app.sdk.platforms.bd.music_info import get_song_info
from app.sdk.platforms.bd.util import first_non_empty
_TSID_RE = re.compile(r"music\.91q\.com/song/(\w+)", re.ASCII)
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:歌曲链接 → Song(尽力附带直链 url/type)。"""
match = _TSID_RE.search(str(link if link is not None else ""))
if not match:
raise RuntimeError("无效的千千歌曲链接")
tsid = match.group(1)
song: dict[str, Any] = dict(await get_song_info(tsid))
try:
# 延迟导入:`apis/bd.py` 依赖本包的 util,顶层导入会让包初始化与 apis 互等
from app.sdk.apis.bd import get_download_url
hit = await get_download_url(tsid)
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
pass
song.pop("lyricUrl", None)
song.pop("raw", None)
return {**song, "songmid": first_non_empty(song.get("songmid"), tsid), "source": "bd"}
async def parse_album(link: str) -> dict[str, Any]:
return await album_module.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist_module.parse_playlist_link(link)
+201
View File
@@ -0,0 +1,201 @@
"""千千音乐歌单(对应 sdk/bd/playlist.ts)。
详情:`/v1/tracklist/info?id=&type=0&pageNo=&pageSize=`(支持分页,默认 pageSize=20)。
分类:`/v1/tracklist/category`(group.categoryName + subCate[])。
分类歌单:`/v1/tracklist/list?subCateId=&pageNo=&pageSize=`(不传 subCateId 为全部)。
搜索:上游用 `/v1/search?type=6`,但 2026-10 实测服务端已下线该搜索类型
(errno 23001 "不被支持的搜索类型(10000)"),官网搜索页也只有 单曲/歌手/专辑 三个 tab。
上游在 !state 时静默返回空列表,本实现保持一致并保留 typeSonglist 解析以备恢复。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.bd.util import (
APP_ID,
api_get,
api_ok,
error_text,
first_non_empty,
map_track,
playlist_link,
to_int,
)
def _map_playlist_summary(item: Any) -> dict[str, Any]:
source = item if isinstance(item, dict) else {}
id = "" if source.get("id") is None else str(source.get("id"))
raw_desc = str(source.get("desc") or "").strip()
tag_list = source.get("tagList")
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
return {
"id": id,
"name": str(source.get("title") or "").strip(),
"cover": str(source.get("pic") or "").strip(),
"trackCount": to_int(source.get("trackCount"), 0),
"creator": first_non_empty(
source.get("creator"),
source.get("author"),
source.get("userName"),
source.get("nickName"),
source.get("ownerName"),
),
"description": raw_desc or "、".join(tags),
"link": playlist_link(id) if id else "",
"source": "bd",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表)。"""
word = str(keyword if keyword is not None else "").strip()
empty: dict[str, Any] = {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
if not word:
return empty
body = await api_get(
"/v1/search",
{"word": word, "type": "6", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
)
# 上游此处只看 body.state(不看 errno),与服务端下线后的行为一致
if not isinstance(body, dict) or not body.get("state"):
return empty
data = body.get("data") or {}
items = data.get("typeSonglist") if isinstance(data, dict) else None
summaries = [_map_playlist_summary(item) for item in items] if isinstance(items, list) else []
list_out = [item for item in summaries if item["id"]]
total = to_int(data.get("total") if isinstance(data, dict) else None, len(list_out))
return {"source": "bd", "list": list_out, "total": total, "page": page, "limit": limit}
async def get_playlist_detail(id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize)。"""
playlist_id = str(id if id is not None else "").strip()
if not playlist_id:
raise RuntimeError("千千歌单 id 为空")
body = await api_get(
"/v1/tracklist/info",
{
"id": playlist_id,
"type": "0",
"pageNo": str(max(1, page)),
"pageSize": str(max(1, limit)),
"appid": APP_ID,
},
)
if not api_ok(body):
raise RuntimeError(error_text(body, "千千歌单获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
raw_tracks = data.get("trackList")
tracks = [item for item in raw_tracks if isinstance(item, dict)] if isinstance(raw_tracks, list) else []
songs = [song for song in (map_track(item) for item in tracks) if song["songmid"]]
title = first_non_empty(data.get("title"), playlist_id)
tag_list = data.get("tagList")
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
cover = first_non_empty(data.get("pic"), songs[0]["img"] if songs else None)
creator = first_non_empty(
data.get("creator"),
data.get("author"),
data.get("userName"),
data.get("nickName"),
data.get("ownerName"),
)
description = first_non_empty(data.get("desc"), data.get("description")) or "、".join(tags)
total = to_int(data.get("trackCount"), len(songs))
return {
"source": "bd",
"info": {
"id": playlist_id,
"name": title,
"img": cover,
"desc": description,
"author": creator,
"link": playlist_link(playlist_id),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
"allPage": math.ceil(total / max(1, limit)) or 1,
}
async def get_playlist_songs(id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只取曲目列表。"""
detail = await get_playlist_detail(id, 1, 1000)
return detail["list"]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:`{source, list: [{id, name, group, count}]}`,首项为「全部」(id='')。"""
body = await api_get("/v1/tracklist/category", {"appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千歌单分类获取失败"))
list_out: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
groups = body.get("data") if isinstance(body, dict) else None
for group in groups if isinstance(groups, list) else []:
if not isinstance(group, dict):
continue
group_name = str(group.get("categoryName") or "").strip()
sub_cate = group.get("subCate")
for item in sub_cate if isinstance(sub_cate, list) else []:
if not isinstance(item, dict):
continue
id = str(item.get("id") or "").strip()
name = str(item.get("categoryName") or "").strip()
if not id or not name:
continue
list_out.append({"id": id, "name": name, "group": group_name, "count": to_int(item.get("count"), 0)})
return {"source": "bd", "list": list_out}
async def get_category_playlists(category_id: Any, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists(page >= 1,limit 1..100)。"""
cate_id = str(category_id if category_id is not None else "").strip()
page_no = max(1, to_int(page, 1))
page_size = min(100, max(1, to_int(limit, 30)))
params: dict[str, Any] = {"appid": APP_ID, "pageNo": str(page_no), "pageSize": str(page_size)}
if cate_id:
params["subCateId"] = cate_id
body = await api_get("/v1/tracklist/list", params)
if not api_ok(body):
raise RuntimeError(error_text(body, "千千分类歌单获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
result = data.get("result")
summaries = (
[{**_map_playlist_summary(item), "categoryId": cate_id} for item in result] if isinstance(result, list) else []
)
list_out = [item for item in summaries if item["id"]]
total = to_int(data.get("total"), len(list_out))
return {"source": "bd", "list": list_out, "total": total, "page": page_no, "limit": page_size}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id。"""
raw = str(link if link is not None else "").strip()
patterns = (
re.compile(r"music\.91q\.com/(?:songlist|tracklist|playlist)/([A-Za-z0-9]+)"),
re.compile(r"(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)"),
)
for pattern in patterns:
match = pattern.search(raw)
if match:
return await get_playlist_detail(match.group(1))
if raw and "/" not in raw:
return await get_playlist_detail(raw)
raise RuntimeError("无效的千千歌单链接")
+296
View File
@@ -0,0 +1,296 @@
"""千千音乐(music.91q.com / 91q.com)公共请求层(对应 sdk/bd/util.ts)。
上游全部接口都走 `/v1/*`,参数 = 业务参数 + appid + timestamp + sign:
sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 只影响 VIP、收藏等登录态资源。
**签名串与 query 必须分开编码**:sign 用参数原值拼接(服务端拿收到的参数值重算),
query 用 `URLSearchParams` 序列化。两者共用同一份 `sign_params` 结果,且 URL 拼好后
直接交给 `http_fetch`(不再二次序列化),这是历史上「签名串 ≠ 实际 body」踩过的坑。
"""
from __future__ import annotations
import hashlib
import math
import re
import time
from typing import Any
from app.sdk.auth_store import get_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import size_formate
APP_ID = "16073360"
SECRET = "0b50b02fd0d73a9c4c8c3a781c30845f"
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
)
REFERER = "https://music.91q.com/player"
API_BASE = "https://music.91q.com"
# 上游音质档位顺序(3000 = flac,其余为 kbps)
RATE_ORDER = ["3000", "320", "128", "64"]
# URLSearchParams 不编码的字符集。与 Python quote 的默认安全字符不同:
# URLSearchParams 保留 `*` 却编码 `~`,差一个字符服务端就验签失败,故自行实现。
_FORM_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._")
# rateFileInfo 档位 → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
_RATE_LABEL = {"128": "128k", "320": "320k", "3000": "flac"}
# 上游 sanitizeQianqianAlbumKeyword 的特殊字符集
_ALBUM_KEYWORD_SPECIALS = re.compile(r"""[::"'“”‘’()()\[\]【】,,/\\\-.]""")
def to_number(value: Any) -> float:
"""JS `Number()` 语义:None(undefined) 与不可解析 → NaN,bool → 1/0,空串 → 0。"""
if value is None:
return math.nan
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return 0.0
try:
return float(text)
except ValueError:
return math.nan
def num_or_zero(value: Any) -> float:
"""`Number(value ?? 0)` 语义:缺失(null/undefined)视作 0,不可解析才是 NaN。"""
return 0.0 if value is None else to_number(value)
def to_int(value: Any, fallback: int = 0) -> int:
"""`Number(value) || fallback` 语义:0 / NaN / 不可解析都落到 fallback。"""
number = to_number(value)
if not number or math.isnan(number):
return fallback
return int(number)
def form_encode(value: Any) -> str:
"""`new URLSearchParams(...).toString()` 的逐字节等价实现(空格写 `+`,其余按 UTF-8 编码)。"""
out: list[str] = []
for byte in str(value).encode("utf-8"):
char = chr(byte)
if char in _FORM_SAFE:
out.append(char)
elif char == " ":
out.append("+")
else:
out.append(f"%{byte:02X}")
return "".join(out)
def get_qian_cookie() -> str:
"""千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致)。"""
return get_cookie("bd")
def request_headers() -> dict[str, str]:
headers = {
"User-Agent": USER_AGENT,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
cookie = get_qian_cookie()
if cookie:
headers["Cookie"] = cookie
return headers
def sign_params(params: dict[str, Any]) -> dict[str, Any]:
"""写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5。"""
signed: dict[str, Any] = {str(key): value for key, value in params.items()}
signed["timestamp"] = str(int(time.time()))
payload = "&".join(f"{key}={signed[key]}" for key in sorted(signed)) + SECRET
signed["sign"] = hashlib.md5(payload.encode("utf-8")).hexdigest()
return signed
def signed_url(path: str, params: dict[str, Any]) -> str:
"""签名后的完整 URL:签名用原值,query 用 URLSearchParams 等价编码。"""
query = "&".join(f"{key}={form_encode(value)}" for key, value in sign_params(params).items())
return f"{API_BASE}{path}?{query}"
async def api_get(path: str, params: dict[str, Any]) -> Any:
"""发起带签名的 GET,返回响应 JSON body(非 200 按原文案抛错)。"""
resp = await http_fetch(signed_url(path, params), method="get", headers=request_headers(), timeout=15000)
if resp.status_code != 200:
raise RuntimeError(f"千千音乐接口失败(HTTP {resp.status_code})")
return resp.body if resp.body is not None else {}
def api_ok(body: Any) -> bool:
"""上游状态判定:state=true 或 errno=22000 视为成功。"""
if not isinstance(body, dict):
return False
return body.get("state") is True or to_number(body.get("errno")) == 22000
def error_text(body: Any, prefix: str) -> str:
"""上游错误文案:errmsg 优先,缺失时拼 errno(两种文案都逐字沿用原实现)。"""
if not isinstance(body, dict):
return prefix
message = str(body.get("errmsg") or "").strip()
if message:
return message
return f"{prefix}(errno {body.get('errno')})"
async def track_link(tsid: str, rate: str) -> Any:
"""tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path)。"""
return await api_get("/v1/song/tracklink", {"TSID": tsid, "appid": APP_ID, "rate": rate})
def interval_of(duration: Any) -> str | int:
"""时间格式 mm:ss(duration 为秒;无效或 <=0 显示 0)。"""
seconds = to_number(duration)
if math.isnan(seconds) or seconds <= 0:
return 0
return f"{int(seconds // 60):02d}:{int(seconds % 60):02d}"
def join_artists(artists: Any) -> str:
"""千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接)。"""
if not isinstance(artists, list) or not artists:
return ""
names: list[str] = []
seen: set[str] = set()
def add(name: Any) -> None:
text = "" if name is None else str(name).strip()
if not text or text in seen:
return
seen.add(text)
names.append(text)
for item in artists:
if isinstance(item, dict) and to_number(item.get("artistType")) == 38:
add(item.get("name"))
if not names:
for item in artists:
if isinstance(item, dict):
add(item.get("name"))
return "、".join(names)
def normalize_album_asset_code(id: Any) -> str:
"""上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P。"""
text = ("" if id is None else str(id)).strip()
if len(text) < 2:
return ""
return f"P{text[1:]}" if text[0] in ("p", "P") else ""
def sanitize_album_keyword(keyword: Any) -> str:
"""上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(23001 时重试用)。"""
trimmed = ("" if keyword is None else str(keyword)).strip()
if not trimmed:
return ""
return " ".join(part for part in _ALBUM_KEYWORD_SPECIALS.sub(" ", trimmed).split() if part)
def first_non_empty(*values: Any) -> str:
for value in values:
text = "" if value is None else str(value).strip()
if text:
return text
return ""
def release_date10(raw: Any) -> str:
"""上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD)。"""
text = ("" if raw is None else str(raw)).strip()
return text[:10] if len(text) >= 10 else text
def rate_stats(rate_file_info: Any, duration: float) -> dict[str, int]:
"""上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps)。"""
for rate in RATE_ORDER:
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
if not isinstance(info, dict):
continue
size = to_number(info.get("size"))
if not size > 0:
continue
if duration > 0:
return {"size": int(size), "bitrate": int(size * 8 / 1000 / duration)}
return {"size": int(size), "bitrate": 800 if rate == "3000" else int(rate)}
return {"size": 0, "bitrate": 0}
def rate_types(rate_file_info: Any) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""rateFileInfo → 既有 Quality 桶的 types / _types。"""
types: list[dict[str, Any]] = []
type_map: dict[str, dict[str, Any]] = {}
for rate in ("128", "320", "3000"):
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
if not isinstance(info, dict):
continue
size = to_number(info.get("size"))
if not size > 0:
continue
label = _RATE_LABEL[rate]
size_text = size_formate(int(size))
types.append({"type": label, "size": size_text})
type_map[label] = {"size": size_text}
return types, type_map
def map_track(
item: Any,
*,
album_id: str = "",
album_name: str = "",
img: str | None = None,
singer: str = "",
) -> dict[str, Any]:
"""千千曲目字段 → MusicServer 统一 Song 形状。
搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
歌单详情用 TSID + albumTitle + pic。
注:上游 Search 会跳过 isVip != 0 的曲目;本实现保留它们并标记 payPlay,
取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
"""
source = item if isinstance(item, dict) else {}
duration = num_or_zero(source.get("duration"))
stats = rate_stats(source.get("rateFileInfo"), duration)
types, type_map = rate_types(source.get("rateFileInfo"))
return {
"songmid": first_non_empty(source.get("TSID"), source.get("assetId"), source.get("tsid")),
"name": str(source.get("title") or "").strip(),
"singer": first_non_empty(join_artists(source.get("artist")), singer),
"albumId": first_non_empty(normalize_album_asset_code(source.get("albumAssetCode")), album_id),
"albumName": first_non_empty(source.get("albumTitle"), album_name),
"interval": interval_of(duration),
"size": stats["size"] or 0,
"bitrate": stats["bitrate"] or 0,
"payPlay": num_or_zero(source.get("isVip")) != 0 or num_or_zero(source.get("isPaid")) != 0,
"img": first_non_empty(source.get("pic"), img) or None,
"lrc": None,
"lrcx": "",
"types": types,
"_types": type_map,
"source": "bd",
}
def song_link(tsid: str) -> str:
return f"https://music.91q.com/song/{tsid}"
def album_link(id: str) -> str:
return f"https://music.91q.com/album/{id}"
def playlist_link(id: str) -> str:
return f"https://music.91q.com/songlist/{id}"
+57
View File
@@ -0,0 +1,57 @@
"""哔哩哔哩(bili)平台模块(对应 sdk/bili/index.ts)。
上游 music-lib/bilibili 的移植,能力位:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单
(合集 ugc_season + 多分P稿件)/ 链接解析(BV/av、分P)/ 封面 / 扫码登录。
不实现(上游即未实现):专辑、歌词(空实现)、榜单、热搜、歌手、评论、个人歌单。
取流入口在 ``app/sdk/apis/bili.py``,songmid 约定 ``<bvid>|<cid>``。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bili import lyric, music_search, parse, pic, playlist
from app.sdk.platforms.bili.util import parse_song_id
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:apis 模块反向依赖本包的 util,顶层导入不必要地增加耦合。"""
from app.sdk.apis.bili import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str:
return await pic.get_pic(song_info)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return "https://www.bilibili.com/"
try:
page = int(song_info.get("page") or 0)
except (TypeError, ValueError):
page = 0
return f"https://www.bilibili.com/video/{bvid}{f'?p={page}' if page > 1 else ''}"
MODULE = Platform(
id="bili",
name="哔哩哔哩",
music_search=music_search,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+14
View File
@@ -0,0 +1,14 @@
"""Bilibili 歌词(对应 sdk/bili/lyric.ts)。
上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串。
B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
返回统一结构 ``{"lyric": "", "tlyric": ""}``,让 /lyric/bili 有响应而不是 404。
"""
from __future__ import annotations
from typing import Any
async def get_lyric(_song_info: dict[str, Any] | None = None) -> dict[str, str]:
return {"lyric": "", "tlyric": ""}
+83
View File
@@ -0,0 +1,83 @@
"""Bilibili 搜索(对应 sdk/bili/musicSearch.ts,上游 bilibili/song.go 的 ``Search``)。
接口: x/web-interface/search/type?search_type=video
上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),
本移植保持一致,只是把逐个串行改成 4 并发;单条 view 失败按上游语义跳过该条。
实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;
风控时返回 -412/-352,会抛错由路由兜底。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
clean_title,
encode_uri_component,
fetch_view,
make_song,
map_limit,
to_number,
)
SEARCH_URL = "https://api.bilibili.com/x/web-interface/search/type"
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,B 站搜索无重试语义
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
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 ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
pages = as_list(view.get("pages"))
if not pages:
return None
first = pages[0] if isinstance(pages[0], dict) else {}
root_title = clean_title(item.get("title"))
display_title = str(first.get("part") or "")
if not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
return make_song(
bvid=str(item["bvid"]),
cid=first.get("cid"),
name=display_title,
artist=str(item.get("author") or owner.get("name") or ""),
album=str(item["bvid"]),
duration=first.get("duration"),
cover=str(item.get("pic") or view.get("pic") or ""),
page=1,
)
songs = await map_limit(items, 4, one)
result = [song for song in songs if song]
total = int(to_number(data.get("numResults"))) or len(result)
num_pages = int(to_number(data.get("numPages")))
return {
"list": result,
"allPage": num_pages or p + (1 if len(result) >= size else 0),
"total": total,
"limit": size,
"source": "bili",
}
+93
View File
@@ -0,0 +1,93 @@
"""Bilibili 链接解析(对应 sdk/bili/parse.ts,上游 bilibili/song.go 的 ``Parse``)。
支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parse_playlist_link。
上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.playlist import parse_playlist_link
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
extract_aid,
extract_bvid,
extract_page,
fetch_page_list,
fetch_view,
make_song,
normalize_cover,
)
async def parse_song(link: str) -> dict[str, Any]:
raw = str(link or "").strip()
bvid = extract_bvid(raw)
aid = extract_aid(raw)
if not bvid and not aid:
raise RuntimeError("无效的B站链接:未找到 BV/av 号")
view = await fetch_view(bvid or aid)
pages = as_list(view.get("pages"))
# 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if view.get("ugc_season") or len(pages) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
if len(pages) <= 1:
try:
page_list = await fetch_page_list(view.get("bvid") or bvid)
if len(page_list) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
except Exception as err:
if "歌单解析" in str(err):
raise
# 分P列表接口失败时按上游语义忽略
if not pages:
raise RuntimeError("未找到视频分P")
page = extract_page(raw)
if page > len(pages):
page = 1
target = pages[page - 1] if isinstance(pages[page - 1], dict) else {}
root_title = str(view.get("title") or "")
display_title = str(target.get("part") or "")
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
cid = str(target.get("cid") if target.get("cid") is not None else "")
song = make_song(
bvid=str(view.get("bvid") or bvid),
cid=cid,
name=display_title,
artist=str(owner.get("name") or ""),
album=str(view.get("bvid") or bvid),
duration=target.get("duration"),
cover=normalize_cover(view.get("pic") or ""),
page=page,
)
# 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if cid:
try:
from app.sdk.apis.bili import get_audio_url
audio = await get_audio_url(str(song["bvid"]), cid)
urls = audio.get("urls") or []
if urls:
song["url"] = urls[0]
song["type"] = audio.get("type")
except Exception: # noqa: BLE001 - 忽略:元数据仍可返回
pass
return song
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单/合集链接解析(上游 bilibili/playlist.go 的 ``ParsePlaylist``)。"""
return await parse_playlist_link(link)
+21
View File
@@ -0,0 +1,21 @@
"""Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import fetch_view, normalize_cover, parse_song_id
async def get_pic(song_info: dict[str, Any]) -> str:
direct = str(song_info.get("img") or "")
if direct.startswith(("http://", "https://")):
return direct
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return ""
try:
view = await fetch_view(bvid)
return normalize_cover(view.get("pic") or "")
except Exception: # noqa: BLE001 - 与原文一致:封面取不到返回空串,不影响调用方
return ""
+318
View File
@@ -0,0 +1,318 @@
"""Bilibili 歌单(对应 sdk/bili/playlist.ts,上游 bilibili/playlist.go)。
B 站的「歌单」= 合集 ugc_season / 多分P稿件:
* search_playlist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
* get_playlist_detail / parse_playlist_link: id 约定 ``season:<season_id>:<mid>:<bvid>`` 或 ``bvid:<bvid>``;
* get_categories / get_category_playlists: 上游显式返回 ErrPlaylistCategoriesUnsupported,保持不支持(抛错)。
注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bili.music_search import SEARCH_URL
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
build_songs_from_pages,
build_songs_from_season_sections,
clean_title,
encode_uri_component,
extract_aid,
extract_bvid,
fetch_page_list,
fetch_season_archive_index,
fetch_season_songs,
fetch_view,
map_limit,
normalize_cover,
to_number,
)
def _count_season_episodes(sections: list[Any]) -> int:
count = 0
for sec in sections or []:
if isinstance(sec, dict):
episodes = sec.get("episodes")
count += len(episodes) if isinstance(episodes, list) else 0
return count
def _season_key(season_id: Any, mid: Any, bvid: str) -> str:
return f"season:{season_id}:{mid}:{bvid}"
def _bvid_key(bvid: str) -> str:
return f"bvid:{bvid}"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力)。"""
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
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 ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
seen: set[str] = set()
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
cover = normalize_cover(item.get("pic") or view.get("pic") or "")
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count and season.get("id") and owner.get("mid"):
try:
track_count = len(await fetch_season_songs(owner.get("mid"), season.get("id")))
except Exception: # noqa: BLE001 - 上游 fetchSeasonSongs 失败同样保持 trackCount 0
pass
stat = as_dict(season.get("stat"))
return {
"id": playlist_id,
"name": str(season.get("title") or clean_title(item.get("title"))),
"cover": normalize_cover(season.get("cover")) or cover,
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
"creator": str(owner.get("name") or item.get("author") or ""),
"description": str(season.get("intro") or ""),
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
pages = as_list(view.get("pages"))
if len(pages) > 1:
playlist_id = _bvid_key(str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
return {
"id": playlist_id,
"name": clean_title(item.get("title")),
"cover": cover,
"trackCount": len(pages),
"creator": str(owner.get("name") or item.get("author") or ""),
"description": "",
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
return None
result = await map_limit(items, 4, one)
result_list = [item for item in result if item]
return {"list": result_list, "page": p, "limit": size, "total": len(result_list), "source": "bili"}
async def _detail_from_bvid(bvid: str) -> dict[str, Any]:
"""由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据)。"""
view = await fetch_view(bvid)
real_bvid = str(view.get("bvid") or bvid)
pages = as_list(view.get("pages"))
if len(pages) <= 1:
try:
page_list = await fetch_page_list(real_bvid)
except Exception: # noqa: BLE001 - 分P列表接口失败按上游语义忽略
page_list = []
if page_list:
pages = page_list
if not pages:
raise RuntimeError("未找到视频分P")
owner = as_dict(view.get("owner"))
songs = build_songs_from_pages(
real_bvid, str(view.get("title") or ""), str(owner.get("name") or ""), str(view.get("pic") or ""), pages
)
return {
"info": {
"name": str(view.get("title") or real_bvid),
"img": normalize_cover(view.get("pic") or ""),
"desc": str(view.get("desc") or ""),
"author": str(owner.get("name") or ""),
},
"songs": songs,
}
async def _detail_from_season(season_id: str, mid: str, bvid: str) -> dict[str, Any]:
"""由 ``season:<season_id>:<mid>:<bvid>`` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支)。"""
info: dict[str, Any] = {"name": "", "img": "", "desc": "", "author": ""}
songs: list[dict[str, Any]] = []
if bvid:
view = await fetch_view(bvid)
season = as_dict(view.get("ugc_season")) or None
if season:
archive_index: dict[str, dict[str, Any]] = {}
season_title = str(season.get("title") or "")
season_cover = str(season.get("cover") or "")
# 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
archive_index = idx["index"]
season_title = season_title or idx["title"]
season_cover = season_cover or idx["cover"]
owner = as_dict(view.get("owner"))
songs = build_songs_from_season_sections(
as_list(season.get("sections")),
season_title,
season_cover,
str(owner.get("name") or ""),
archive_index,
)
info = {
"name": season_title or str(view.get("title") or ""),
"img": normalize_cover(season_cover or view.get("pic") or ""),
"desc": str(season.get("intro") or ""),
"author": str(owner.get("name") or ""),
}
if not songs:
songs = await fetch_season_songs(mid, season_id)
if not info["name"]:
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
info = {**info, "name": idx["title"] or "", "img": normalize_cover(idx["cover"] or "")}
if not songs:
raise RuntimeError("合集为空或合集接口不可用")
return {"info": info, "songs": songs}
async def _resolve_id(playlist_id: str) -> dict[str, Any]:
"""解析 id(兼容链接/裸 BV/av/bvid: 前缀)。"""
raw = str(playlist_id or "").strip()
if re.match(r"^https?:", raw, re.IGNORECASE):
return {"kind": "link", "value": raw}
if re.match(r"^season:", raw, re.IGNORECASE):
return {"kind": "season", "value": raw, "parts": raw.split(":")}
# 注意先去 `bvid:` 前缀再提 BV:大小写不敏感的 BV 匹配会把前缀 "bvid" 本身当成 BV 号
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if bvid:
return {"kind": "bvid", "value": bvid}
aid = extract_aid(raw)
if aid:
view = await fetch_view(aid)
real_bvid = str(view.get("bvid") or "")
if not real_bvid:
raise RuntimeError("未找到视频 BV 号")
return {"kind": "bvid", "value": real_bvid}
raise RuntimeError("无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)")
def _page_slice(detail: dict[str, Any], page: Any, limit: Any) -> dict[str, Any]:
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 100), 10000)
songs = detail["songs"]
return {
"source": "bili",
"info": detail["info"],
"list": songs[(p - 1) * size : p * size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(契约键 playlist.get_playlist_detail)。"""
target = await _resolve_id(playlist_id)
if target["kind"] == "link":
return await parse_playlist_link(target["value"])
if target["kind"] == "season":
parts = target.get("parts") or []
if len(parts) < 3:
raise RuntimeError("无效的合集 id")
bvid = parts[3] if len(parts) > 3 else ""
return _page_slice(await _detail_from_season(parts[1], parts[2], bvid), page, limit)
return _page_slice(await _detail_from_bvid(target["value"]), page, limit)
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs 的等价导出(只返回歌曲数组)。"""
detail = await get_playlist_detail(playlist_id, 1, 10000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量)。"""
raw = str(link or "").strip()
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if not bvid:
aid = extract_aid(raw)
if not aid:
raise RuntimeError("无效的B站歌单链接:未找到 BV/av 号")
view = await fetch_view(aid)
return await _parse_playlist_view(str(view.get("bvid") or ""), view)
view = await fetch_view(bvid)
return await _parse_playlist_view(bvid, view)
async def _parse_playlist_view(bvid: str, view: dict[str, Any]) -> dict[str, Any]:
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), bvid)
detail = await _detail_from_season(
str(season.get("id")), str(owner.get("mid") if owner.get("mid") is not None else ""), bvid
)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count:
track_count = len(detail["songs"])
stat = as_dict(season.get("stat"))
return {
"source": "bili",
"id": playlist_id,
"info": {
**detail["info"],
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
detail = await _detail_from_bvid(bvid)
return {
"source": "bili",
"id": _bvid_key(bvid),
"info": {**detail["info"], "trackCount": len(detail["songs"])},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
+425
View File
@@ -0,0 +1,425 @@
"""Bilibili 公共请求助手(对应 sdk/bili/util.ts,上游 music-lib/bilibili 的共享逻辑)。
与上游的差异(2026-10-02 实测):
1) 上游硬编码的默认 cookie(buvid3=...; SESSDATA=your_sessdata;)当前会被 B 站风控:
带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
2) 请求层不跟随 302,取流的手动跟跳在 ``app/sdk/apis/bili.py`` 内处理。
3) 登录凭据来自 ``app/sdk/auth_store.py`` 的 bili 记录(扫码登录写入)。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"
)
REFERER = "https://www.bilibili.com/"
HOME_URL = "https://www.bilibili.com/"
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def bili_headers(cookie: str = "") -> dict[str, str]:
"""上游 UserAgent/Referer 组合(所有接口统一携带)。"""
headers = {
"User-Agent": UA,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
if cookie:
headers["Cookie"] = cookie
return headers
def get_login_cookie() -> str:
"""已保存的登录 Cookie(未登录返回空串)。"""
cred = get_credentials("bili") or {}
return str(cred.get("cookie") or "")
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
async def _fetch_home_cookies() -> str:
"""首页下发的新鲜 Cookie(http_fetch 保留了原始多值 Set-Cookie,同名头不会互相覆盖)。"""
resp = await http_fetch(HOME_URL, headers=bili_headers(), timeout=15_000)
jar = _cookie_map(resp.set_cookies)
return "; ".join(f"{key}={value}" for key, value in jar.items())
_anon_cookie = ""
_anon_lock = asyncio.Lock()
async def get_cookie() -> str:
"""请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘)。"""
saved = get_login_cookie()
if saved:
return saved
global _anon_cookie
if _anon_cookie:
return _anon_cookie
async with _anon_lock:
if _anon_cookie:
return _anon_cookie
try:
_anon_cookie = await _fetch_home_cookies()
except Exception: # noqa: BLE001 - 拿不到就用无 Cookie 请求(实测同样可用)
_anon_cookie = ""
return _anon_cookie
async def api_get(url: str, cookie: str | None = None) -> HttpResponse:
"""带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名)。"""
ck = await get_cookie() if cookie is None else cookie
return await http_fetch(url, headers=bili_headers(ck), timeout=15_000)
def normalize_cover(cover: Any) -> str:
"""封面协议补全(上游 normalizeCover)。"""
text = str(cover or "")
if not text:
return ""
return f"https:{text}" if text.startswith("//") else text
def clean_title(title: Any) -> str:
"""搜索标题去高亮标签(上游 cleanTitle)。"""
if not title:
return ""
return re.sub(r'<em class="keyword">|</em>', "", str(title))
def parse_song_id(songmid: Any) -> dict[str, str]:
"""songmid 约定:``<bvid>|<cid>``(与上游 ID 格式一致)。"""
raw = str(songmid if songmid is not None else "")
idx = raw.find("|")
if idx < 0:
return {"bvid": raw, "cid": ""}
return {"bvid": raw[:idx], "cid": raw[idx + 1 :]}
def extract_bvid(link: str) -> str:
"""从链接/id 提取 BV 号(兼容小写 bv)。"""
match = re.search(r"BV[0-9A-Za-z]+", str(link or ""), re.IGNORECASE)
return match.group(0) if match else ""
def extract_page(link: str) -> int:
"""从链接提取分P(上游 ``[?&]p=(\\d+)``)。"""
match = re.search(r"[?&]p=(\d+)", str(link or ""))
page = int(match.group(1)) if match else 0
return page if page > 0 else 1
def extract_aid(link: str) -> str:
"""从链接提取 av 号(上游未支持,任务要求兼容)。"""
match = re.search(r"\bav(\d+)\b", str(link or ""), re.IGNORECASE)
return match.group(1) if match else ""
def to_number(value: Any) -> float:
"""JS 的 Number(value):空值/非数字一律 0。"""
try:
return float(value)
except (TypeError, ValueError):
return 0.0
async def map_limit(items: list[Any], limit: int, fn: Any) -> list[Any]:
"""并发映射(限制并发数,单条失败返回 None)。"""
source = list(items or [])
out: list[Any] = [None] * len(source)
if not source:
return out
size = max(1, min(int(limit or 1), len(source)))
cursor = 0
async def worker() -> None:
nonlocal cursor
while True:
index = cursor
cursor += 1
if index >= len(source):
return
try:
out[index] = await fn(source[index], index)
except Exception: # noqa: BLE001 - 单条失败按上游语义跳过
out[index] = None
await asyncio.gather(*(worker() for _ in range(size)))
return out
def make_song(
*,
bvid: str,
cid: Any,
name: str = "",
artist: str = "",
album: str = "",
duration: Any = 0,
cover: str = "",
page: int = 1,
) -> dict[str, Any]:
"""统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid)。"""
seconds = to_number(duration)
link = f"https://www.bilibili.com/video/{bvid}" + (f"?p={page}" if page > 1 else "")
return {
"songmid": f"{bvid}|{cid}",
"name": name or "",
"singer": artist or "",
"albumId": bvid or "",
"albumName": album or bvid or "",
"interval": format_play_time(seconds) if seconds else 0,
"img": normalize_cover(cover) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "bili",
"bvid": bvid,
"cid": str(cid if cid is not None else ""),
"page": page,
"link": link,
}
def assert_api_ok(what: str, status_code: int, body: Any) -> None:
"""B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误。"""
if status_code != 200 or not isinstance(body, dict) or body.get("code") != 0:
code = body.get("code") if isinstance(body, dict) else None
message = body.get("message") if isinstance(body, dict) else None
reason = code if code is not None else f"HTTP {status_code}"
raise RuntimeError(f"B站{what}获取失败({reason}{f': {message}' if message else ''})")
async def fetch_view(video_id: Any) -> dict[str, Any]:
"""视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点。"""
raw = str(video_id or "").strip()
if not raw:
raise RuntimeError("B站视频 id 为空")
if re.match(r"^BV", raw, re.IGNORECASE):
query = f"bvid={encode_uri_component(raw)}"
else:
query = f"aid={encode_uri_component(re.sub('^av', '', raw, flags=re.IGNORECASE))}"
resp = await api_get(f"https://api.bilibili.com/x/web-interface/view?{query}")
assert_api_ok("视频信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
if not isinstance(data, dict):
raise RuntimeError("B站视频信息获取失败(data 为空)")
return data
async def fetch_page_list(bvid: str) -> list[Any]:
"""分P列表(x/player/pagelist),返回 data 数组。"""
resp = await api_get(f"https://api.bilibili.com/x/player/pagelist?bvid={encode_uri_component(bvid)}")
assert_api_ok("分P列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
return data if isinstance(data, list) else []
def _season_page_url(mid: Any, season_id: Any, page_num: int, page_size: int) -> str:
return (
"https://api.bilibili.com/x/space/ugc/season?"
f"mid={mid}&season_id={season_id}&page_num={page_num}&page_size={page_size}"
)
async def fetch_season_archive_index(mid: Any, season_id: Any) -> dict[str, Any]:
"""合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex)。
该接口现已 404,调用方需容错;返回 ``{"index", "title", "cover"}``,
index 以 ``bvid:<bvid>`` / ``cid:<cid>`` 为键。
"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
index: dict[str, dict[str, Any]] = {}
season_title = ""
season_cover = ""
processed = 0
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict):
continue
meta = {
"title": str(arc.get("title") or ""),
"cover": normalize_cover(arc.get("cover") or ""),
"duration": to_number(arc.get("duration")),
}
if arc.get("bvid"):
index[f"bvid:{arc['bvid']}"] = meta
if arc.get("cid"):
index[f"cid:{arc['cid']}"] = meta
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return {"index": index, "title": season_title, "cover": season_cover}
def build_songs_from_pages(
bvid: str, root_title: str, author: str, cover: str, pages: list[Any]
) -> list[dict[str, Any]]:
"""由分P数组构造歌曲(上游 buildSongsFromPages)。"""
songs: list[dict[str, Any]] = []
for i, page in enumerate(pages or []):
part = str(page.get("part") or "") if isinstance(page, dict) else ""
display_title = part
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
songs.append(
make_song(
bvid=bvid,
cid=page.get("cid") if isinstance(page, dict) else None,
name=display_title,
artist=author,
album=bvid,
duration=page.get("duration") if isinstance(page, dict) else 0,
cover=cover,
page=i + 1,
)
)
return songs
def build_songs_from_season_sections(
sections: list[Any],
season_title: str,
season_cover: str,
artist_name: str,
archive_index: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections)。"""
index = archive_index or {}
songs: list[dict[str, Any]] = []
artist = artist_name or season_title
for sec in sections or []:
if not isinstance(sec, dict):
continue
for ep in sec.get("episodes") or []:
if not isinstance(ep, dict):
continue
cover = str(ep.get("cover") or "")
meta = index.get(f"bvid:{ep.get('bvid')}") or (index.get(f"cid:{ep.get('cid')}") if ep.get("cid") else None)
if not cover:
arc = as_dict(ep.get("arc"))
cover = str(arc.get("pic") or "")
if not cover:
cover = season_cover or (meta or {}).get("cover", "")
duration = to_number(ep.get("duration"))
if not duration:
page = as_dict(ep.get("page"))
duration = to_number(page.get("duration"))
if not duration:
arc = as_dict(ep.get("arc"))
duration = to_number(arc.get("duration"))
if not duration:
duration = to_number((meta or {}).get("duration"))
page = as_dict(ep.get("page"))
arc = as_dict(ep.get("arc"))
name = str(ep.get("title") or arc.get("title") or page.get("part") or (meta or {}).get("title") or "")
songs.append(
make_song(
bvid=str(ep.get("bvid") or ""),
cid=ep.get("cid"),
name=name,
artist=artist,
album=str(ep.get("bvid") or ""),
duration=duration,
cover=cover,
page=1,
)
)
return songs
async def fetch_season_songs(mid: Any, season_id: Any) -> list[dict[str, Any]]:
"""合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错)。"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
all_songs: list[dict[str, Any]] = []
processed = 0
season_title = ""
season_cover = ""
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict) or not arc.get("cid"):
continue
all_songs.append(
make_song(
bvid=str(arc.get("bvid") or ""),
cid=arc.get("cid"),
name=str(arc.get("title") or ""),
artist="",
album=season_title or str(arc.get("bvid") or ""),
duration=arc.get("duration"),
cover=normalize_cover(arc.get("cover") or season_cover),
page=1,
)
)
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return all_songs
+68
View File
@@ -0,0 +1,68 @@
"""5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块(对应 sdk/5sing/index.ts)。
上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
榜单/热搜/歌手/评论(上游均无)。
取流入口在 ``app/sdk/apis/fivesing.py``;songmid 约定 ``<songId>|<songType>``(如 "932557|yc")。
登录:上游 New(cookie) 的 cookie 会随请求发送;本移植从 auth_store 的 5sing 记录读取(无则匿名)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.fivesing import lyric, music_search, parse, playlist
from app.sdk.platforms.fivesing.util import build_song_link, extract_song_id, get_status
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现,避免 apis → 本包 util 的初始化顺序耦合。"""
from app.sdk.apis.fivesing import get_music_url
return await get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
ids = extract_song_id(song_info.get("songmid") or song_info.get("id"), song_info.get("songType"))
if not ids["songID"] or not ids["songType"]:
return "http://5sing.kugou.com/"
return build_song_link(ids["songType"], ids["songID"])
class _UserPlaylist:
"""上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义。"""
@staticmethod
async def get_list(_page: int = 1, _limit: int = 20) -> dict[str, Any]:
raise RuntimeError("user playlists not supported")
user_playlist = _UserPlaylist()
def status() -> dict[str, Any]:
return get_status()
MODULE = Platform(
id="5sing",
name="5sing",
music_search=music_search,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_music_detail_page_url=get_music_detail_page_url,
)
+41
View File
@@ -0,0 +1,41 @@
"""5sing 歌词(对应 sdk/5sing/lyric.ts,上游 fivesing/lyric.go 的 GetLyrics)。
接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
→ data.dynamicWords 即 LRC 文本。
上游 ID 约定:model.Song.ID = "<songid>|<songtype>",从 songmid 的 "|" 拆分
(路由层只透传 songmid),也接受 songInfo.songType 字段。
统一接口:返回 ``{lyric, tlyric: ''}``(5sing 无翻译歌词)。
实况(2026-10-23 匿名实测):newget 响应不再包含 dynamicWords(搜索前 10 条与多个关键词均如此,
TS 版同样结果),因此此刻匿名取歌词会如实抛 "lyrics not found";绑定 cookie 后是否有变化尚未验证。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_dynamic_lyric
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
async def get_lyric_text(song_id: str, song_type: str) -> str:
"""直接返回歌词文本(内部/测试用)。"""
lyric = await fetch_dynamic_lyric(song_id, song_type)
if not lyric:
raise RuntimeError("lyrics not found")
return lyric
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
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"]:
raise RuntimeError("invalid id")
lyric = await fetch_dynamic_lyric(ids["songID"], ids["songType"])
if not lyric:
raise RuntimeError("lyrics not found")
return {"lyric": lyric, "tlyric": ""}
+118
View File
@@ -0,0 +1,118 @@
"""5sing 歌曲信息 / 取流底层(对应 sdk/5sing/musicInfo.ts)。
链路(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink):
取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址;
上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
get_first_valid,
kind_name,
)
QUALITY_TIERS = ("sq", "hq", "lq")
def _tier_url(data: dict[str, Any], tier: str) -> str:
"""data 里某档位的可用地址(主地址 → backup)。"""
return get_first_valid(data.get(f"{tier}url"), data.get(f"{tier}url_backup"))
async def fetch_audio_link(song_id: str, song_type: str, prefer_low: bool = False) -> dict[str, str]:
"""上游 fetchAudioLink:仅获取音频链接。"""
url = (
f"{MOBILE_API}/song/getSongUrl?songid={encode_uri_component(song_id)}"
f"&songtype={encode_uri_component(song_type)}"
)
resp = await fetch_5sing_json(url)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 取流失败(HTTP {resp.status_code})")
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
if code != 1000:
# 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
message = body.get("message")
raise RuntimeError(f"5sing 取流失败({message})" if message else "api returned error code")
data = as_dict(body.get("data"))
order = tuple(reversed(QUALITY_TIERS)) if prefer_low else QUALITY_TIERS
for tier in order:
hit = _tier_url(data, tier)
if hit and hit.startswith(("http://", "https://")):
return {"type": tier, "url": hit}
raise RuntimeError("no valid download url found")
async def fetch_new_get(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象)。"""
url = f"{MOBILE_API}/song/newget?songid={encode_uri_component(song_id)}&songtype={encode_uri_component(song_type)}"
resp = await fetch_5sing_json(url)
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 歌曲信息获取失败(HTTP {resp.status_code})")
data = resp.body.get("data")
return data if isinstance(data, dict) else {}
async def fetch_dynamic_lyric(song_id: str, song_type: str) -> str:
"""上游 newget 歌词:data.dynamicWords(LRC 文本)。"""
data = await fetch_new_get(song_id, song_type)
return str(data.get("dynamicWords") or "")
async def fetch_song_info(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。"""
hit = await fetch_audio_link(song_id, song_type)
name = ""
artist = ""
cover = ""
try:
data = await fetch_new_get(song_id, song_type)
name = str(data.get("SN") or "")
user = as_dict(data.get("user"))
artist = str(user.get("NN") or "")
cover = str(user.get("I") or "")
except Exception: # noqa: BLE001 - 上游 newget 失败时继续(不阻断)
pass
if not name:
name = f"5sing_{song_type}_{song_id}"
return {
"songmid": f"{song_id}|{song_type}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": cover or None,
"lrc": None,
"types": [],
"_types": {},
"url": hit["url"],
"type": hit["type"],
"link": build_song_link(song_type, song_id),
"songType": song_type,
"typeName": kind_name(song_type),
"source": "5sing",
}
def song_interval(song_size: Any) -> int:
"""时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式)。"""
return estimate_duration(song_size)
@@ -0,0 +1,73 @@
"""5sing 歌曲搜索(对应 sdk/5sing/musicSearch.ts,上游 fivesing/song.go 的 Search)。
接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;
本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import (
SEARCH_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
def map_search_song(item: dict[str, Any]) -> dict[str, Any]:
"""上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing')。"""
song_type = str(item.get("typeEname") or "")
duration = estimate_duration(item.get("songSize"))
return {
"songmid": f"{item.get('songId', '')}|{song_type}",
"name": remove_em_tags(unescape_html(item.get("songName"))),
"singer": remove_em_tags(unescape_html(item.get("singer") or item.get("nickName"))),
"albumId": "",
"albumName": "",
"interval": format_play_time(duration) if duration > 0 else 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
# 统一种类字段(上游 typeEname / typeName)
"songType": song_type,
"typeName": str(item.get("typeName") or kind_name(song_type)),
"size": int(item.get("songSize") or 0),
"link": build_song_link(song_type, item.get("songId")),
"source": "5sing",
}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "allPage": 0, "total": 0, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=0"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
songs = [map_search_song(item) for item in raw_list[:page_size] if isinstance(item, dict)]
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(songs)
# 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
all_page = int(page_info.get("totalPages") or 0) or (page_no + 1 if len(songs) >= page_size else page_no)
return {"list": songs, "allPage": all_page, "total": total, "limit": page_size, "source": "5sing"}
+25
View File
@@ -0,0 +1,25 @@
"""5sing 链接解析(对应 sdk/5sing/parse.ts)。
歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetch_song_info(直链 + newget 元数据,无直链即报错)
歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId)
专辑: 上游无专辑概念(不实现)
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_song_info
from app.sdk.platforms.fivesing.playlist import parse_playlist_link
from app.sdk.platforms.fivesing.util import extract_song_link
async def parse_song(link: str) -> dict[str, Any]:
parsed = extract_song_link(link)
if not parsed:
raise RuntimeError("invalid 5sing link")
return await fetch_song_info(parsed["songID"], parsed["songType"])
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+282
View File
@@ -0,0 +1,282 @@
"""5sing 歌单(对应 sdk/5sing/playlist.ts,上游 fivesing/fivesing.go + playlist.go)。
搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/...
上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植并发 5。
详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块
③ 数字 userId 页会 302 到个性域名页,fetch_5sing 已手动跟跳。
分类/个人歌单:上游显式返回不支持,本移植保留同样语义(抛错)。
链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名与省略 user 段,ID 以 API 反查为准。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
SEARCH_API,
build_playlist_link,
build_song_link,
encode_uri_component,
extract_playlist_link,
fetch_5sing,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:显式不支持。"""
raise RuntimeError("playlist categories not supported")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:显式不支持。"""
raise RuntimeError("playlist categories not supported")
def _map_playlist_song(song_id: str, kind: str, name: str, artist: str) -> dict[str, Any]:
"""歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状。"""
return {
"songmid": f"{song_id}|{kind}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
"songType": kind,
"typeName": kind_name(kind),
"link": build_song_link(kind, song_id),
"source": "5sing",
}
def parse_songs_from_html(html_content: str) -> list[dict[str, Any]]:
"""上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
块正则 ``<li class="p_rel">([\\s\\S]*?)</li>``;块内 href 提取 yc/fc/bz 与 id,
s_soner 提取歌手(上游的拼写错误 soner 保留)。
"""
blocks = re.findall(r'<li class="p_rel">([\s\S]*?)</li>', str(html_content or ""))
if not blocks:
raise RuntimeError("no songs found in playlist html (structure mismatch)")
# 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
song_re = re.compile(r'href="(?:(?:https?:)?//)?5sing\.kugou\.com/(yc|fc|bz)/(\d+)\.html"[^>]*>([^<]+)</a>')
artist_re = re.compile(r'class="s_soner[^"]*".*?>([^<]+)</a>')
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for block in blocks:
song_match = song_re.search(block)
if not song_match:
continue
kind, song_id, raw_name = song_match.group(1), song_match.group(2), song_match.group(3)
artist = "Unknown"
artist_match = artist_re.search(block)
if artist_match:
artist = artist_match.group(1)
unique_key = f"{kind}|{song_id}"
if unique_key in seen:
continue
seen.add(unique_key)
name = remove_em_tags(unescape_html(raw_name)).strip()
artist = remove_em_tags(unescape_html(artist)).strip()
songs.append(_map_playlist_song(song_id, kind, name, artist))
return songs
async def fetch_creator_name(playlist_id: str) -> str:
"""上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名。"""
if not playlist_id:
return ""
try:
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=user"
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return ""
data = resp.body.get("data")
if not isinstance(data, dict):
return ""
user = as_dict(data.get("user"))
return str(user.get("NN") or "")
except Exception: # noqa: BLE001 - 反查失败保留 ID 兜底
return ""
def _map_playlist_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐)。"""
playlist_id = str(item.get("songListId") or "")
user_id = str(item.get("userId") or "")
desc = remove_em_tags(unescape_html(item.get("content")))
creator = str(item.get("userName") or "") or (f"ID: {user_id}" if user_id else "")
return {
"id": playlist_id,
"name": remove_em_tags(unescape_html(item.get("title"))),
"cover": str(item.get("pictureUrl") or ""),
"trackCount": int(item.get("songCnt") or 0),
"playCount": int(item.get("playCount") or 0),
"creator": creator,
"creatorId": user_id,
# 上游怪癖:content 为字符串 "0" 时视为空简介
"description": "" if desc == "0" else desc,
"link": build_playlist_link(user_id, playlist_id),
"source": "5sing",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist(type=1),补创建者名(并发 5)。"""
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "total": 0, "page": page_no, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=1"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 歌单搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
playlists = [_map_playlist_summary(item) for item in raw_list[:page_size] if isinstance(item, dict)]
# 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
targets = [pl for pl in playlists if pl["creatorId"] and pl["creator"] == f"ID: {pl['creatorId']}"]
for start in range(0, len(targets), 5):
chunk = targets[start : start + 5]
async def fill(pl: dict[str, Any]) -> None:
name = await fetch_creator_name(pl["id"])
if name:
pl["creator"] = name
await asyncio.gather(*(fill(pl) for pl in chunk))
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(playlists)
return {"list": playlists, "total": total, "page": page_no, "limit": page_size, "source": "5sing"}
async def fetch_playlist_all(playlist_id_raw: str) -> dict[str, Any]:
"""元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全)。"""
playlist_id = str(playlist_id_raw or "").strip()
if not playlist_id:
raise RuntimeError("invalid 5sing playlist id")
# 1) API 元数据(标题/封面/统计/UserID)
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=ID,user"
)
if resp.status_code != 200:
raise RuntimeError(f"fetch info failed: HTTP {resp.status_code}")
# 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
body = as_dict(resp.body)
data = body.get("data")
if not isinstance(data, dict):
raise RuntimeError("playlist info not found or invalid (api returned empty list)")
user = as_dict(data.get("user"))
user_id = str(user.get("ID") or "")
if not user_id:
raise RuntimeError("playlist user not found or invalid id")
playlist = {
"id": playlist_id,
"name": str(data.get("T") or ""),
"cover": str(data.get("P") or ""),
"trackCount": int(data.get("E") or 0),
"playCount": int(data.get("H") or 0),
"creator": str(user.get("NN") or ""),
"description": str(data.get("C") or ""),
"link": build_playlist_link(user_id, playlist_id),
"userId": user_id,
"source": "5sing",
}
# 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch_5sing 已跟跳)
page = await fetch_5sing(playlist["link"])
if page.status_code != 200:
raise RuntimeError(f"fetch html failed: HTTP {page.status_code}")
# 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
songs: list[dict[str, Any]] = []
try:
songs = parse_songs_from_html(str(page.body if isinstance(page.body, str) else ""))
except Exception: # noqa: BLE001 - 结构不匹配时返回空列表
songs = []
return {"playlist": playlist, "songs": songs}
def _playlist_info(playlist: dict[str, Any]) -> dict[str, Any]:
return {
"id": playlist["id"],
"name": playlist["name"],
"img": playlist["cover"],
"desc": playlist["description"],
"author": playlist["creator"],
"link": playlist["link"],
"trackCount": playlist["trackCount"],
"playCount": playlist["playCount"],
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E)。"""
detail = await fetch_playlist_all(playlist_id)
playlist, songs = detail["playlist"], detail["songs"]
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
start = (page_no - 1) * page_size
total = len(songs)
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs[start : start + page_size],
"page": page_no,
"limit": page_size,
"total": total,
"allPage": math.ceil(total / page_size) or 1,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:仅返回曲目列表(全量)。"""
detail = await fetch_playlist_all(playlist_id)
return detail["songs"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(全量曲目)。"""
parsed = extract_playlist_link(link)
if not parsed:
raise RuntimeError("invalid 5sing playlist link")
detail = await fetch_playlist_all(parsed["playlistId"])
playlist, songs = detail["playlist"], detail["songs"]
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs,
"page": 1,
"limit": len(songs) or 1,
"total": len(songs),
"allPage": 1,
}
+196
View File
@@ -0,0 +1,196 @@
"""5sing 公共工具(对应 sdk/5sing/util.ts,上游 fivesing/fivesing.go 的等价实现)。
平台 id 统一为 ``5sing``(上游 Go 包名 fivesing,model.Source 用 "fivesing";本仓库契约要求 5sing)。
上游 ``New(cookie)`` 的 cookie 实际参与请求(搜索 / getSongUrl / newget / 歌单页),
这里从 auth_store 的 ``5sing`` 记录读取(无则匿名)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
UserAgent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
)
#: 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302)
MOBILE_API = "http://mobileapi.5sing.kugou.com"
#: 搜索接口(type=0 单曲 / type=1 歌单)
SEARCH_API = "http://search.5sing.kugou.com/home/json"
#: 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏)
SONG_KINDS: dict[str, str] = {"yc": "原创", "fc": "翻唱", "bz": "伴奏"}
HTML_ENTITIES: dict[str, str] = {
"amp": "&",
"lt": "<",
"gt": ">",
"quot": '"',
"apos": "'",
"nbsp": " ",
"copy": "©",
"reg": "®",
"hellip": "…",
"mdash": "—",
"ndash": "–",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def get_first_valid(*urls: Any) -> str:
"""上游 getFirstValid:返回第一个非空字符串(空串视为无效)。"""
for url in urls:
if url:
return str(url)
return ""
def remove_em_tags(text: Any) -> str:
"""去掉搜索结果里 ``<em class="keyword">`` 高亮标签。"""
return str(text if text is not None else "").replace('<em class="keyword">', "").replace("</em>", "").strip()
def unescape_html(text: Any) -> str:
"""Go html.UnescapeString 的等价实现(常用实体 + 数字实体)。"""
source = str(text if text is not None else "")
def repl(match: re.Match[str]) -> str:
entity = match.group(1)
try:
if entity.startswith(("#x", "#X")):
return chr(int(entity[2:], 16))
if entity.startswith("#"):
return chr(int(entity[1:], 10))
except ValueError:
return match.group(0)
return HTML_ENTITIES.get(entity.lower(), match.group(0))
return re.sub(r"&(#x?[0-9a-fA-F]+|[a-zA-Z]+);", repl, source)
def get_5sing_cookie() -> str:
"""上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用)。"""
cred = get_credentials("5sing") or {}
return str(cred.get("cookie") or "")
def get_status() -> dict[str, Any]:
"""登录态(凭据存在性判断)。"""
cred = get_credentials("5sing") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
def is_foreign_source(song_info: dict[str, Any]) -> bool:
"""是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查)。"""
source = song_info.get("source")
return bool(source) and source != "5sing"
async def fetch_5sing(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""带 Cookie/UA 的请求;httpFetch 不跟随 302,这里手动跟跳最多 3 次。
5sing 老链接会 http→https / 数字 userId → 个性域名跳转,必须跟。
"""
cookie = get_5sing_cookie()
all_headers = {"User-Agent": UserAgent}
if cookie:
all_headers["Cookie"] = cookie
if headers:
all_headers.update({str(key): str(value) for key, value in headers.items()})
current = str(url)
response = None
for _hop in range(4):
response = await http_fetch(current, headers=all_headers, timeout=timeout_ms)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
from urllib.parse import urljoin
try:
current = urljoin(current, location)
except ValueError:
return response
continue
return response
return response
async def fetch_5sing_json(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""JSON 接口带退避重试(手机端 API 频率控制:code 13 / "操作太频繁")。"""
response = None
for attempt in range(3):
response = await fetch_5sing(url, headers=headers, timeout_ms=timeout_ms)
body = response.body if isinstance(response.body, dict) else {}
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
message = str(body.get("message") or "")
if (code == 13 or "太频繁" in message) and attempt < 2:
await asyncio.sleep(0.8 * (attempt + 1))
continue
return response
return response
def build_song_link(song_type: str, song_id: Any) -> str:
"""歌曲页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{song_type}/{song_id}.html" if song_type and song_id else ""
def build_playlist_link(user_id: Any, playlist_id: str) -> str:
"""歌单页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{user_id}/dj/{playlist_id}.html" if user_id and playlist_id else ""
def extract_song_id(songmid: Any, song_type_fallback: Any = None) -> dict[str, str]:
"""解析歌曲的 ``<songid>|<songtype>`` 约定(兼容只给 songType 字段的调用)。"""
raw = str(songmid if songmid is not None else "")
parts = raw.split("|")
id_part = parts[0] if parts else ""
type_part = parts[1] if len(parts) > 1 else ""
return {"songID": id_part or "", "songType": str(type_part or song_type_fallback or "")}
def extract_song_link(link: Any) -> dict[str, str] | None:
"""上游 Parse 的正则:5sing.kugou.com/(\\w+)/(\\d+).html(专辑/歌手页不匹配)。"""
match = re.search(r"5sing\.kugou\.com/(\w+)/(\d+)\.html", str(link if link is not None else ""))
if not match:
return None
return {"songType": match.group(1), "songID": match.group(2)}
def extract_playlist_link(link: Any) -> dict[str, str] | None:
"""歌单链接解析(放宽为任意单段 userId,含个性域名与省略 user 段)。"""
match = re.search(
r"5sing\.kugou\.com/(?:([^/?#]+)/)?dj/([a-zA-Z0-9]+)\.html", str(link if link is not None else "")
)
if not match:
return None
return {"userId": match.group(1) or "", "playlistId": match.group(2)}
def estimate_duration(song_size: Any) -> int:
"""上游 Search 的时长估算:songSize*8/320000(320kbps 假设;0 返回 0)。"""
try:
size = float(song_size or 0)
except (TypeError, ValueError):
size = 0.0
return math.trunc((size * 8) / 320000) if size > 0 else 0
def kind_name(song_type: str) -> str:
"""作品类型中文名(typeName 缺失时兜底)。"""
return SONG_KINDS.get(song_type, "")
+85
View File
@@ -0,0 +1,85 @@
"""JOOX(joox)平台模块(对应 sdk/joox/index.ts)。
匿名可用面(2026-10 实测,www 站点需 X-Forwarded-For 东南亚 IP 绕过地区拦截):
搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+链接解析)/ 歌词 / 链接解析(歌曲)/
取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
通道:
* 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(**无签名**)
* 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(**需 md5 签名**,见 util.joox_sign)
* 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
* 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
⚠ 网络连通性实况(本机出口 IP 非东南亚):
* cache.api.joox.com 与 api.joox.com 的 JSON 接口全部可直连;
* www.joox.com 的 SSR 页面返回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__,
因此 ``playlist.get_categories`` / ``playlist.get_category_playlists`` 与专辑·歌单的页面兜底
在当前网络下会如实抛错(不返回假数据)。这与 TS 注释一致:TS 也依赖伪造 X-Forwarded-For,
本移植带的是同一个头(``util.JOOX_XFF``),能否生效取决于上游是否采信,实测本机未被采信。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.apis.joox import get_music_url
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.joox import album, lyric, music_search, parse, playlist
from app.sdk.platforms.joox.music_info import get_track_detail
from app.sdk.platforms.joox.util import joox_song_link, normalize_joox_id, pick_joox_image
class JOOXSongList:
"""契约别名 ``songList``(对应 TS index.ts 里的内联 shim)。
JOOX 没有独立的「排行榜/歌曲列表」接口:``getTags`` 即歌单分类,``getList`` 即某分类下的歌单。
``sort_list`` 只有「全部」一项,因此路由透传的 ``sort_id`` 恒为空串,``tag_id`` 才是分类号。
TS 的 ``getList(sortId, tagId, page, limit=30)`` 中 ``limit`` 固定在内部,路由不透传。
"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "全部", "id": ""}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: str = "", tag_id: str = "", page: int = 1) -> dict[str, Any]:
return await playlist.get_category_playlists(str(tag_id or sort_id or ""), page, 30)
async def _get_pic(song_info: dict[str, Any]) -> str | None:
"""封面(对应 TS index.ts 的 getPic):走 openjoox2 track 详情后挑图,取不到返回 None。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or "")
detail = await get_track_detail(song_id)
return pick_joox_image(detail.get("images")) or None
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return joox_song_link(song_info.get("songmid") or song_info.get("id") or "")
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断;无在线校验接口)。"""
cred = get_credentials("joox")
if cred and cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="joox",
name="JOOX",
music_search=music_search,
song_list=JOOXSongList,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=_get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
View File
Whitespace-only changes.
+221
View File
@@ -0,0 +1,221 @@
"""JOOX 专辑(对应 sdk/joox/album.ts,上游 joox/album.go 移植 + 新版 openjoox2 通道)。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``section_type=1`` 专辑区,``item.type=2``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
(web 播放器通道,需 md5 签名;实测 2026-10 HTTP 200)
兜底:www.joox.com/<region>/album/<id> SSR ``__NEXT_DATA__.props.pageProps.albumData``
⚠ 该兜底依赖 X-Forwarded-For 伪装的东南亚 IP;本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此该路径在当前网络环境下不可用,仅在 openjoox2 失败时尝试,失败即如实抛出。
链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
路径里的 album id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径,
故路径写法不影响 secret 校验。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_album_link,
joox_artists,
joox_headers,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
to_int,
)
async def fetch_album_tracks(album_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 专辑曲目(含专辑元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/album/{album_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
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"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")), album_id)
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), album_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": joox_artists(body.get("artist_list")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def fetch_album_page_data(album_id: str) -> dict[str, Any]:
"""SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分)。"""
page = await fetch_joox_page(joox_album_link(album_id))
next_data = extract_joox_next_data(page["body"])
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
album_data = page_props.get("albumData")
if isinstance(album_data, dict) and normalize_joox_id(album_data.get("id")):
return album_data
nested = ((page_props.get("content") or {}).get("page") or {}).get("albumData")
if isinstance(nested, dict) and normalize_joox_id(nested.get("id")):
return nested
raise RuntimeError("joox 专辑页数据不存在")
async def get_album_detail_from_page(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径)。"""
album_id = normalize_joox_id(album_id)
if not album_id:
raise RuntimeError("joox 专辑 id 无效")
album_data = await fetch_album_page_data(album_id)
track_list = album_data.get("trackList")
items = track_list.get("items") if isinstance(track_list, dict) else None
items = items if isinstance(items, list) else []
page_start = max(0, (page - 1) * limit)
page_items = items[page_start : page_start + limit]
cover = str(first_non_empty(pick_joox_image(album_data.get("images")), album_data.get("imgSrc")))
songs = [
song
for song in (
joox_song_from_track(item, str(album_data.get("title") or ""), cover, album_id) for item in page_items
)
if song
]
if not songs:
raise RuntimeError("joox 专辑无歌曲")
total = first_non_zero(
track_list.get("total_count") if isinstance(track_list, dict) else None,
track_list.get("list_count") if isinstance(track_list, dict) else None,
len(items),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(album_data.get("title"), album_id)),
"img": cover or None,
"desc": str(first_non_empty(album_data.get("description"), album_data.get("publishDate"))),
"author": joox_artists(album_data.get("artistList")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(openjoox2 优先,SSR 页兜底)。"""
normalized = normalize_joox_id(album_id)
if not normalized:
raise RuntimeError("joox 专辑 id 无效")
try:
detail = await fetch_album_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走页面兜底
pass
return await get_album_detail_from_page(normalized, page, limit)
async def get_album_songs(album_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""专辑曲目(上游 GetAlbumSongs:仅曲目列表)。"""
detail = await get_album_detail(album_id, page, limit)
return detail["list"]
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
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 not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
albums: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict) or to_int(section.get("section_type")) != 1:
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or to_int(item.get("type")) != 2:
continue
raw_album = item.get("album")
album = raw_album if isinstance(raw_album, dict) else {}
album_id = normalize_joox_id(album.get("id"))
if not album_id or album_id in seen:
continue
seen.add(album_id)
publish_date = str(album.get("publish_date") or "").strip()
albums.append(
{
"id": album_id,
"name": str(album.get("name") or ""),
"cover": pick_joox_image(album.get("images")) or None,
"creator": joox_artists(album.get("artist_list")),
"description": publish_date,
"publishDate": publish_date,
"trackCount": 0,
"link": joox_album_link(album_id),
"source": "joox",
}
)
return {"list": albums[:limit], "total": len(albums), "page": page, "limit": limit, "source": "joox"}
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/album/([^/?#]+)",
r"h_activity_id=([^&]+)",
r"albumid=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 10 and "/" not in stripped:
return await get_album_detail(stripped)
raise RuntimeError("invalid joox album link")
+92
View File
@@ -0,0 +1,92 @@
"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。
主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
响应形如 ``MusicJsonCallback({"code":0,"lyric":"<base64>"})``,lyric 为 base64 UTF-8 歌词。
实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。
兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。
解码:base64 → UTF-8 → HTML 实体(&amp; 等)→ ``{ lyric, tlyric: "" }``。
QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。
"""
from __future__ import annotations
import json
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_lyric,
encode_uri_component,
first_non_empty,
joox_headers,
normalize_joox_id,
)
LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric"
def parse_lyric_payload(body: Any) -> dict[str, Any] | None:
"""解析 MusicJsonCallback(...) / 裸 JSON 包裹。"""
if isinstance(body, dict):
return body
text = str(body if body is not None else "")
index = text.find("MusicJsonCallback(")
if index >= 0:
text = text[index + len("MusicJsonCallback(") :]
text = text.rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
if text.endswith(")"):
text = text[:-1].rstrip()
try:
parsed = json.loads(text)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def parse_legacy_lyric(status_code: int, body: Any) -> str:
"""上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。"""
if status_code != 200:
raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})")
payload = parse_lyric_payload(body)
if not payload:
raise RuntimeError("joox 歌词解析失败")
raw = str(first_non_empty(payload.get("lyric"), ""))
if int(payload.get("code") or 0) != 0 or not raw:
raise RuntimeError("joox 歌词不存在或为空")
return decode_joox_lyric(raw)
async def get_legacy_lyric(song_id: str) -> str:
"""上游 GetLyrics 通道(仅旧接口,测试/内部用)。"""
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return parse_legacy_lyric(res.status_code, res.body)
async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]:
"""歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
# 1) 上游 web_lyric 通道
legacy_err: Exception | None = None
try:
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""}
except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底
legacy_err = err
# 2) 兜底:openjoox2 详情 lrc_content(base64)
res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body if isinstance(res.body, dict) else {}
raw = str(first_non_empty(body.get("lrc_content"), ""))
if res.status_code != 200 or not raw:
raise legacy_err or RuntimeError("joox 歌词不存在或为空")
return {"lyric": decode_joox_lyric(raw), "tlyric": ""}
+103
View File
@@ -0,0 +1,103 @@
"""JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)。
对应 sdk/joox/musicInfo.ts。
端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
* 参数按**插入序**参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
* 签名串与真正发出的参数来自同一次序列化(同一份 params dict + 同一 encoder),不做二次拼接。
* 实测(2026-10):不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};
按上式 → 200,返回 play_url_list / refrain_url / lrc_content / qrc_content / vip_flag 等。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 前),本模块为其替代。
``fs=1`` 请求完整曲目(游客仍可能拿到 preview/refrain),``im`` 为 mobile 标记。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
cookie_value,
encode_uri_component,
joox_headers,
joox_login_cookie,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
)
JOOX_OPENJOOX2 = "https://cache.api.joox.com/openjoox2/v1"
def build_track_url(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> str:
"""构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk)。"""
track_id = str(normalize_joox_id(raw_id)).replace("/", "_")
params: dict[str, Any] = {
"country": country or "hk",
"lang": normalize_joox_lang(lang or "zh_TW"),
"lyric": 1,
"fs": 1 if full_song else 0,
"im": 1 if is_mobile else 0,
}
# 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
login_cookie = joox_login_cookie()
uid = cookie_value(login_cookie, "wmid") if login_cookie else ""
usk = cookie_value(login_cookie, "session_key") if login_cookie else ""
if uid:
params["uid"] = uid
if usk:
params["usk"] = usk
# 签名时包含 id(与 web 播放器一致),发布 query 时 id 走路径、不进 query
secret = joox_sign({**params, "id": track_id})
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in {**params, "secret": secret}.items())
return f"{JOOX_OPENJOOX2}/track/{track_id}?{query}"
async def get_track_detail(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> dict[str, Any]:
"""歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现)。"""
track_id = normalize_joox_id(raw_id)
if not track_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(track_id.replace("/", "_"), country, lang, full_song, is_mobile)
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}'}")
return body
def pick_track_url(detail: Any) -> str:
"""从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 &amp; 实体。"""
if not isinstance(detail, dict):
return ""
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = detail.get("play_url_list")
for url in urls if isinstance(urls, list) else []:
text = clean(url)
if text.startswith("http"):
return text
refrain = clean(detail.get("refrain_url"))
return refrain if refrain.startswith("http") else ""
+57
View File
@@ -0,0 +1,57 @@
"""JOOX 搜索(对应 sdk/joox/musicSearch.ts,上游 joox/song.go Search 移植)。
端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
返回 ``section_list``(0 歌曲 / 1 专辑 / 2 歌手 / 3 歌单 / 5 最佳结果 / 7 用户),
歌曲在 ``item_list[].song[].song_info``。
该接口**无分页参数**(上游也未分页),``page`` 仅为统一契约保留;``allPage`` 恒为 1,
``total`` 是去重后的本次结果条数(不是上游分页总数)。
实测(2026-10):中文/英文关键词均 HTTP 200。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.song import joox_song_from_search
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, encode_uri_component, joox_headers
SEARCH_URL = "https://cache.api.joox.com/openjoox/v3/search"
def build_search_url(keyword: str) -> str:
return f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(keyword or '')}"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索歌曲。``page`` 不参与请求(上游无分页),保留以对齐契约。"""
url = build_search_url(str(keyword or ""))
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 not body.get("section_list"):
return {"list": [], "allPage": 0, "total": 0, "limit": limit, "source": "joox"}
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict):
continue
for entry in item.get("song") or []:
info = entry.get("song_info") if isinstance(entry, dict) else None
song = joox_song_from_search(info)
if not song or song["songmid"] in seen:
continue
seen.add(song["songmid"])
songs.append(song)
return {
"list": songs[:limit],
"allPage": 1,
"total": len(songs),
"limit": limit,
"source": "joox",
}
+43
View File
@@ -0,0 +1,43 @@
"""JOOX 链接解析(对应 sdk/joox/parse.ts)。
歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.get_album_detail
歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.get_playlist_detail
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.joox.album import parse_album as _parse_album
from app.sdk.platforms.joox.music_info import get_track_detail, pick_track_url
from app.sdk.platforms.joox.playlist import parse_playlist as _parse_playlist
from app.sdk.platforms.joox.song import joox_song_from_detail
from app.sdk.platforms.joox.util import normalize_joox_id
_SINGLE_RE = re.compile(r"joox\.com/.*/single/([^/?#]+)")
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则)。"""
raw = str(link or "").strip()
match = _SINGLE_RE.search(raw)
if match:
song_id = normalize_joox_id(match.group(1))
elif len(raw) > 10 and "/" not in raw:
song_id = normalize_joox_id(raw)
else:
raise RuntimeError("invalid joox link")
detail = await get_track_detail(song_id)
return joox_song_from_detail(detail, pick_track_url(detail))
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接 → 专辑详情(上游 ParseAlbum)。"""
return await _parse_album(link)
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接 → 歌单详情(上游 ParsePlaylist)。"""
return await _parse_playlist(link)
+420
View File
@@ -0,0 +1,420 @@
"""JOOX 歌单(对应 sdk/joox/playlist.ts,上游 joox/playlist.go 移植)。
两条路:OpenJOOX 接口 + 网页数据兜底。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``item.type=1`` → ``editor_playlist``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret=
(上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR ``__NEXT_DATA__`` 的
props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
分类:www.joox.com/sg/playlist(302 → /<region> 首页)``__NEXT_DATA__.props.pageProps.mlList``
⚠ 分类与页面兜底都依赖 X-Forwarded-For 伪装的东南亚 IP。本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此 get_categories / get_category_playlists 在当前网络环境下会如实抛错,不返回假数据。
链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID。
路径里的歌单 id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_base64_text,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_headers,
joox_playlist_link,
joox_playlist_links,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
)
CATEGORY_PAGE = "https://www.joox.com/sg/playlist"
def map_playlist_summary(item: Any) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
playlist_id = normalize_joox_id(item.get("id"))
if not playlist_id:
return None
return {
"id": playlist_id,
"name": str(item.get("name") or ""),
"cover": pick_joox_image(item.get("images")) or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(playlist_id),
"source": "joox",
}
async def fetch_playlist_tracks(playlist_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 歌单曲目(含歌单元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/playlist/{playlist_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
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"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")))
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
body.get("track_count"),
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), playlist_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": "",
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
def _track_items(tracks: Any) -> list[Any]:
if not isinstance(tracks, dict):
return []
items = tracks.get("items")
return items if isinstance(items, list) else []
def _track_total(*candidates: Any) -> Any:
return first_non_zero(*candidates)
def _nested_name(detail: dict[str, Any], key: str) -> Any:
"""TS 里 ``detail?.user?.name`` 这类嵌套取值的兑底链(name → userName → nickName → nick)。"""
node = detail.get(key)
if not isinstance(node, dict):
return None
return first_non_empty(node.get("name"), node.get("userName"), node.get("nickName"), node.get("nick"))
def parse_playlist_page_data(next_data: Any, page_url: str) -> dict[str, Any] | None:
"""网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植)。"""
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
detail: dict[str, Any] = page_props.get("playlistDetailList") or {}
tracks = _track_items((page_props.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
nested = (page_props.get("content") or {}).get("page") or {}
detail = nested.get("playlistDetailList") or {}
tracks = _track_items((nested.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
track_count,
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
return None
playlist_id = normalize_joox_id(first_non_empty(detail.get("id"), detail.get("listId"), detail.get("playlistId")))
name = str(first_non_empty(detail.get("name"), detail.get("title"), detail.get("playlistName"), playlist_id))
cover = str(first_non_empty(detail.get("imgSrc"), detail.get("cover"), detail.get("image"), detail.get("pic")))
creator = first_non_empty(
detail.get("creator"),
detail.get("creatorName"),
detail.get("userName"),
detail.get("nickName"),
detail.get("ownerName"),
detail.get("author"),
detail.get("authorName"),
_nested_name(detail, "user"),
_nested_name(detail, "owner"),
_nested_name(detail, "creatorInfo"),
)
description = first_non_empty(detail.get("description"), detail.get("intro"), detail.get("desc"))
return {
"id": playlist_id,
"name": name,
"cover": cover,
"creator": creator,
"description": description,
"trackCount": track_count,
"tracks": tracks,
"pageUrl": page_url,
}
async def get_playlist_detail_from_page(raw_id: str) -> dict[str, Any]:
"""网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径)。"""
playlist_id = normalize_joox_id(raw_id)
if not playlist_id:
raise RuntimeError("joox 歌单 id 无效")
last_err: Exception | None = None
for page_url in joox_playlist_links(playlist_id):
try:
page = await fetch_joox_page(page_url)
parsed = parse_playlist_page_data(extract_joox_next_data(page["body"]), page_url)
if not parsed:
raise RuntimeError("joox 歌单页数据不存在")
page_id = normalize_joox_id(parsed["id"]) or playlist_id
cover = str(parsed["cover"] or "")
songs = [
song
for song in (joox_song_from_track(item, parsed["name"], cover) for item in parsed["tracks"])
if song
]
if not songs:
raise RuntimeError("joox 歌单无歌曲")
return {
"source": "joox",
"info": {
"name": str(parsed["name"] or page_id),
"img": cover or None,
"desc": str(parsed["description"] or ""),
"author": str(parsed["creator"] or ""),
},
"list": songs,
"page": 1,
"limit": len(songs),
"total": first_non_zero(parsed["trackCount"], len(songs)),
}
except Exception as err: # noqa: BLE001 - 逐个区域尝试,全部失败才抛最后一个错误
last_err = err
raise last_err or RuntimeError("joox 歌单页数据不存在")
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留)。"""
normalized = normalize_joox_id(playlist_id)
if not normalized:
raise RuntimeError("joox 歌单 id 无效")
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走网页兜底
pass
return await get_playlist_detail_from_page(normalized)
async def get_playlist_songs(playlist_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底)。"""
normalized = normalize_joox_id(playlist_id)
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail["list"]
except Exception: # noqa: BLE001 - 走网页兜底
pass
from_page = await get_playlist_detail_from_page(normalized)
return from_page["list"]
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(上游 SearchPlaylist)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
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 not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
playlists: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or _as_int(item.get("type")) != 1:
continue
summary = map_playlist_summary(item.get("editor_playlist"))
if not summary or summary["id"] in seen:
continue
seen.add(summary["id"])
playlists.append(summary)
return {"list": playlists[:limit], "total": len(playlists), "page": page, "limit": limit, "source": "joox"}
async def fetch_playlist_categories_page() -> dict[str, Any]:
"""歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage)。"""
page = await fetch_joox_page(CATEGORY_PAGE)
next_data = extract_joox_next_data(page["body"])
ml_list = ((next_data or {}).get("props") or {}).get("pageProps", {})
ml_list = (ml_list or {}).get("mlList")
if not isinstance(ml_list, dict) or not ml_list.get("category"):
raise RuntimeError("joox 歌单分类不存在")
return ml_list
def category_playlist_id(value: Any) -> str:
"""分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一)。"""
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(int(value))
return normalize_joox_id(value)
def _categories(ml_list: dict[str, Any]) -> list[Any]:
categories = ml_list.get("category")
return categories if isinstance(categories, list) else []
async def get_categories() -> dict[str, Any]:
"""分类列表(上游 GetPlaylistCategories;首个为「全部」)。"""
data = await fetch_playlist_categories_page()
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部", "hot": False}]
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
entries = category.get("itemlist")
entries = entries if isinstance(entries, list) else []
if not name or not entries:
continue
items.append({"id": str(index), "name": name, "group": "JOOX", "hot": False, "count": len(entries)})
if len(items) == 1:
raise RuntimeError("joox 歌单分类不存在")
return {"source": "joox", "list": items}
async def get_category_playlists(category_id: str = "", page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类下歌单(上游 GetCategoryPlaylists)。"""
data = await fetch_playlist_categories_page()
cid = str(category_id or "").strip()
items: list[Any] = []
category_name = "全部"
if not cid:
seen: set[str] = set()
for category in _categories(data):
if not isinstance(category, dict):
continue
for item in category.get("itemlist") or []:
entry_id = category_playlist_id(item.get("id") if isinstance(item, dict) else None)
if not entry_id or entry_id in seen:
continue
seen.add(entry_id)
items.append(item)
else:
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
if str(index) == cid or (name and name.lower() == cid.lower()):
entries = category.get("itemlist")
items = entries if isinstance(entries, list) else []
category_name = name
break
if not items:
raise RuntimeError("joox 歌单分类不存在")
start = max(0, (page - 1) * limit)
if start >= len(items):
raise RuntimeError("joox 分类下无歌单")
page_items = items[start : start + limit]
playlists: list[dict[str, Any]] = []
for item in page_items:
if not isinstance(item, dict):
continue
entry_id = category_playlist_id(item.get("id"))
name = decode_joox_base64_text(item.get("title"))
if not entry_id or not name:
continue
playlists.append(
{
"id": entry_id,
"name": name,
"cover": str(item.get("picurl") or "").replace("%d", "300") or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(entry_id),
"categoryId": cid,
"categoryName": category_name,
"source": "joox",
}
)
if not playlists:
raise RuntimeError("joox 分类下无歌单")
return {"list": playlists, "total": len(items), "page": page, "limit": limit, "source": "joox"}
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接解析(上游 ParsePlaylist)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/playlist/([^/?#]+)",
r"(?:playlistid|playlist_id|id)=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_playlist_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 8 and "/" not in stripped:
return await get_playlist_detail(stripped)
raise RuntimeError("invalid joox playlist link")
def _as_int(value: Any) -> int | None:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
+96
View File
@@ -0,0 +1,96 @@
"""JOOX 歌曲形状映射(对应 sdk/joox/song.ts)。
统一 Song 形状(见 tmp/port-spec.md);兼容来源:openjoox2 track/album/playlist 条目、
openjoox v3 search 的 song_info、以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,
上游 Go 也两套都兜底)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.joox.util import (
first_non_empty,
first_non_zero,
joox_artists,
normalize_joox_id,
pick_joox_image,
to_int,
)
def joox_song_from_track(
item: Any,
fallback_album: str = "",
fallback_cover: str = "",
fallback_album_id: str = "",
) -> dict[str, Any] | None:
"""详情/列表 track 条目 → 统一 Song 形状。"""
if not isinstance(item, dict):
item = {}
songmid = normalize_joox_id(item.get("id"))
if not songmid:
return None
artists = item.get("artist_list")
if not (isinstance(artists, list) and artists):
artists = item.get("artistList")
album_id = normalize_joox_id(first_non_empty(item.get("album_id"), item.get("albumId"), fallback_album_id))
return {
"songmid": songmid,
"name": str(first_non_empty(item.get("name"), item.get("title"), "Unknown")),
"singer": str(first_non_empty(joox_artists(artists), "Unknown")),
"albumId": str(album_id or ""),
"albumName": str(first_non_empty(item.get("album_name"), item.get("albumName"), fallback_album)),
"interval": format_play_time(
first_non_zero(item.get("play_duration"), item.get("playDuration"), item.get("duration"))
),
"img": str(
first_non_empty(
pick_joox_image(item.get("images")),
item.get("imgSrc"),
item.get("cover"),
item.get("image"),
item.get("pic"),
fallback_cover,
)
)
or None,
"lrc": None,
"vip": to_int(item.get("vip_flag") if item.get("vip_flag") is not None else 0) == 1,
"types": [],
"_types": {},
"source": "joox",
}
def joox_song_from_search(info: Any) -> dict[str, Any] | None:
"""openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状。"""
return joox_song_from_track(info)
def joox_song_from_detail(detail: Any, play_url: str = "") -> dict[str, Any]:
"""详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状。"""
if not isinstance(detail, dict):
detail = {}
song = joox_song_from_track(detail)
if song is None:
song = {
"songmid": normalize_joox_id(detail.get("id")),
"name": str(detail.get("name") or ""),
"singer": str(joox_artists(detail.get("artist_list"))),
"albumId": str(normalize_joox_id(detail.get("album_id"))),
"albumName": str(detail.get("album_name") or ""),
"interval": format_play_time(first_non_zero(detail.get("play_duration"))),
"img": str(pick_joox_image(detail.get("images"))) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "joox",
}
if play_url:
song["url"] = play_url
song["vip"] = to_int(detail.get("vip_flag") if detail.get("vip_flag") is not None else 0) == 1
return song
+267
View File
@@ -0,0 +1,267 @@
"""JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取(对应 sdk/joox/util.ts)。
背景(与 TS 注释一致,2026-10 实测):
* JOOX 是东南亚服务,www.joox.com 会按来源 IP 返回「Sorry, JOOX is not available in this
location」;上游通过伪造 ``X-Forwarded-For`` 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
* 新版 web 播放器走 ``cache.api.joox.com/openjoox2/v1/*``,**必须**带 secret 签名:
secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent)
密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
实测:不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};按上式 → 200。
* 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
但 web_lyric 仍可用(歌词首选通道)。
* 登录 cookie 里的 wmid/session_key 在签名参数里作为 uid/usk 参与(VIP 完整曲目依赖它)。
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
JOOX_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
"""上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie)。"""
JOOX_DEFAULT_COOKIE = "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;"
"""上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区拦截)。"""
JOOX_XFF = "36.73.34.109"
"""openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey)。"""
JOOX_SECRET_KEY = "Jo0x@t3Nc3nT"
"""平台统一超时(毫秒),与 TS 各处 timeout: 20000 一致。"""
JOOX_TIMEOUT = 20000
def md5_hex(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def joox_sign(params: dict[str, Any]) -> str:
"""openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)。
**键序即 dict 插入序**(服务端按同序校验),不要重排参数 —— 任何 sorted() 都会破坏签名。
"""
pairs = [f"{key}={encode_uri_component(value)}" for key, value in params.items()]
return md5_hex(JOOX_SECRET_KEY + "&".join(pairs))
def joox_login_cookie() -> str:
"""绑定到 auth_store 的登录 cookie(未登录返回空串,不回退游客 cookie)。"""
cred = get_credentials("joox")
if not cred:
return ""
return str(cred.get("cookie") or "")
def joox_cookie() -> str:
"""请求用 cookie(登录态优先;未登录回退上游游客 cookie)。"""
return joox_login_cookie() or JOOX_DEFAULT_COOKIE
def cookie_value(cookie: str, name: str) -> str:
"""从 cookie 串里取一个字段。"""
match = re.search(rf"(?:^|;\s*){re.escape(name)}=([^;]*)", str(cookie or ""))
return match.group(1).strip() if match else ""
def joox_headers(extra: dict[str, Any] | None = None) -> dict[str, str]:
"""平台公共请求头(含地区伪装与 Cookie)。"""
headers = {
"User-Agent": JOOX_UA,
"Cookie": joox_cookie(),
"X-Forwarded-For": JOOX_XFF,
"Referer": "https://www.joox.com/",
"Accept": "application/json, text/plain, */*",
}
if extra:
headers.update({str(key): str(value) for key, value in extra.items()})
return headers
def normalize_joox_lang(lang: str | None) -> str:
"""语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN)。"""
if lang in ("zh_hk", "zh_mo"):
return "zh_TW"
if lang == "zh_cn":
return "zh_CN"
return lang or "zh_TW"
def normalize_joox_id(raw: Any) -> str:
"""ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +)。"""
normalized = str(raw if raw is not None else "").strip()
if not normalized:
return ""
if "%" in normalized:
try:
normalized = unquote(normalized, errors="strict")
except (UnicodeDecodeError, ValueError):
# 上游 PathUnescape/QueryUnescape 失败时保留原值
pass
normalized = normalized.replace(" ", "+")
return normalized
def joox_song_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/single/{normalize_joox_id(raw_id)}"
def joox_album_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/album/{normalize_joox_id(raw_id)}"
def joox_regional_playlist_link(region: str, raw_id: Any) -> str:
return f"https://www.joox.com/{region}/playlist/{normalize_joox_id(raw_id)}"
def joox_playlist_link(raw_id: Any) -> str:
return joox_regional_playlist_link("id", raw_id)
def joox_playlist_links(raw_id: Any) -> list[str]:
"""歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用)。"""
return [joox_regional_playlist_link(region, raw_id) for region in ("id", "sg", "hk")]
def pick_joox_image(images: Any) -> str:
"""封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空)。"""
items = images if isinstance(images, list) else []
for width in (300, 1000, 100):
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if to_int(image.get("width")) == width and url:
return url
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if url:
return url
return ""
def joox_artists(items: Any) -> str:
"""歌手拼接(上游 joinJooxArtists,分隔符 ' / ')。"""
listing = items if isinstance(items, list) else []
names = [str(entry.get("name") or "").strip() for entry in listing if isinstance(entry, dict)]
return " / ".join(name for name in names if name)
def first_non_empty(*values: Any) -> Any:
for value in values:
if value is None:
continue
if str(value).strip() != "":
return value
return ""
def first_non_zero(*values: Any) -> int | float:
for value in values:
number = to_number(value)
if number is not None and number > 0:
return number
return 0
def to_number(value: Any) -> float | None:
"""对齐 JS Number():有限数值返回 float,其余返回 None。"""
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return 0.0
try:
number = float(text)
except ValueError:
return None
else:
return None
if number != number or number in (float("inf"), float("-inf")):
return None
return number
def to_int(value: Any) -> int | None:
number = to_number(value)
return None if number is None else int(number)
def decode_joox_base64_text(value: Any) -> str:
"""base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充)。"""
raw = str(value if value is not None else "").strip()
if not raw:
return ""
normalized = raw.replace("-", "+").replace("_", "/")
padded = normalized + "=" * ((4 - len(normalized) % 4) % 4)
try:
text = base64.b64decode(padded, validate=False).decode("utf-8", errors="replace")
except (ValueError, TypeError):
return raw
# 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
return raw if "\ufffd" in text else text.strip()
def decode_html_entities(text: Any) -> str:
"""HTML 实体解码(歌词文本兜底)。"""
return (
str(text if text is not None else "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&apos;", "'")
.replace("&#0*39;", "'")
.replace("&nbsp;", " ")
)
def decode_joox_lyric(value: Any) -> str:
"""歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底)。"""
return decode_html_entities(decode_joox_base64_text(value))
_NEXT_DATA_RE = re.compile(r'<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)</script>')
def extract_joox_next_data(html: Any) -> Any:
"""从 SSR 页面提取 __NEXT_DATA__ JSON。"""
match = _NEXT_DATA_RE.search(str(html if html is not None else ""))
if not match:
return None
try:
return json.loads(match.group(1))
except ValueError:
return None
async def fetch_joox_page(url: str, max_redirect: int = 3) -> dict[str, Any]:
"""拉取页面并手动跟随 3xx(request.py 不自动跟跳;Go 客户端会自动跟)。
返回 ``{"statusCode", "body", "finalUrl"}``(非字符串 body 视为空串)。
"""
current = url
for _ in range(max_redirect + 1):
res = await http_fetch(current, headers=joox_headers(), timeout=JOOX_TIMEOUT)
location = res.header("location")
if 300 <= res.status_code < 400 and location:
current = urljoin(current, location)
continue
body = res.body if isinstance(res.body, str) else ""
return {"statusCode": res.status_code, "body": body, "finalUrl": current}
raise RuntimeError("joox 页面重定向次数过多")
+65
View File
@@ -0,0 +1,65 @@
"""酷狗音乐(对应 sdk/kg/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析,
顶层挂 get_music_url(取流在 apis/kg.py)、get_lyric、get_pic、get_music_info
(hash 反查歌名/歌手/时长,供取流失败的酷我兜底使用)、get_music_detail_page_url。
"""
from __future__ import annotations
from typing import Any
from app.sdk.apis.kg import get_music_url
from app.sdk.platforms._base import Platform
from app.sdk.platforms.kg import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_info,
music_search,
parse,
pic,
playlist,
singer,
song_list,
)
from app.sdk.platforms.kg.util import get_device
async def _init() -> None:
"""预先固化设备指纹(取流签名依赖 dfid/mid,首次调用时生成并持久化)。"""
get_device()
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
hash_value = song_info.get("hash") or song_info.get("songmid")
album_id = song_info.get("albumId")
return f"https://www.kugou.com/song/#hash={hash_value}&album_id={album_id}"
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""路由兜底调用:传 `{"hash": id, "songmid": id}`,返回含 name/singer/interval 的 dict。"""
return await music_info.get_music_info_for_song_info(song_info)
MODULE = Platform(
id="kg",
name="酷狗音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
singer=singer,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
init=_init,
)
+150
View File
@@ -0,0 +1,150 @@
"""酷狗专辑(对应 sdk/kg/album.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch, encode_uri_component
ALBUM_INFO_URL = (
"http://kmrserviceretry.kugou.com/container/v1/album"
"?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589"
)
ALBUM_SONG_URL = "http://mobiles.kugou.com/api/v3/album/song"
ALBUM_SEARCH_URL = "http://mobilecdn.kugou.com/api/v3/search/album"
_UA_MOBILE = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _album_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
async def _get_album_info(id: Any) -> dict[str, Any]:
result = await create_http_fetch(
ALBUM_INFO_URL,
{
"method": "POST",
"body": {
"appid": 1005,
"clienttime": 1681833686,
"clientver": 11589,
"data": [{"album_id": id}],
"fields": (
"language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,"
"quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,"
"exclusive,is_publish,trans_param,authors,album_tag"
),
"isBuy": 0,
"key": "e6f3306ff7e2afb494e89fbbda0becbf",
"mid": "22945702112173152889429073101964063697",
"show_album_tag": 0,
},
},
)
if not result:
raise RuntimeError("get album info failed.")
info = result[0] if isinstance(result, list) else result
return {
"name": info.get("album_name"),
"image": str(info.get("sizable_cover") or "").replace("{size}", "240"),
"desc": info.get("intro"),
"authorName": info.get("author_name"),
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 200) -> dict[str, Any]:
album_list = await create_http_fetch(
f"{ALBUM_SONG_URL}?version=9108&albumid={album_id}&plat=0&pagesize={limit}"
f"&area_code=0&page={page}&with_res_tag=0"
)
album_list = album_list or {}
if not album_list.get("info"):
raise RuntimeError("Get album list failed.")
songs = await get_music_infos_by_list(album_list.get("info") or [])
info = await _get_album_info(album_id)
return {
"list": songs or [],
"page": page,
"limit": limit,
"total": album_list.get("total"),
"source": "kg",
"info": {
"name": info["name"],
"img": info["image"],
"desc": info["desc"],
"author": info["authorName"],
},
}
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 10))
url = f"{ALBUM_SEARCH_URL}?keyword={encode_uri_component(word)}&format=json&page={page_no}&pagesize={page_size}"
resp = await http_fetch(url, headers={"User-Agent": _UA_MOBILE, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗专辑搜索失败"))
data = body.get("data") or {}
songs: list[dict[str, Any]] = []
for item in data.get("info") or []:
album_id = str(item.get("albumid") or "").strip()
if not album_id or album_id == "0":
continue
songs.append(
{
"id": album_id,
"name": str(item.get("albumname") or "").strip(),
"cover": _album_cover(item.get("imgurl")),
"trackCount": int(item.get("songcount") or 0) or 0,
"creator": str(item.get("singername") or "").strip(),
"description": str(item.get("intro") or "").strip(),
"publishTime": str(item.get("publishtime") or "").strip(),
"link": f"https://www.kugou.com/album/{album_id}.html",
"source": "kg",
"type": "album",
}
)
return {
"source": "kg",
"list": songs,
"page": page_no,
"limit": page_size,
"total": int(data.get("total") or 0) or len(songs),
}
async def get_album_songs(id: Any) -> list[dict[str, Any]]:
album_id = str(id or "").strip()
if not album_id:
raise RuntimeError("酷狗专辑 id 为空")
result = await get_album_detail(album_id, 1, 10000)
return result["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""album/single/<id>.html、yy/album/single/<id>.html、album/<id>.html、albumid=<id>。"""
import re
raw = str(link or "")
for pattern in (
r"album/single/(\d+)\.html",
r"yy/album/single/(\d+)\.html",
r"album/(\d+)\.html",
r"albumid=(\d+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1), 1)
raise RuntimeError("无效的酷狗专辑链接")
+128
View File
@@ -0,0 +1,128 @@
"""酷狗评论(对应 sdk/kg/comment.ts)。
签名:`signatureParams(params)` —— 参数串按键排序拼接后以 android 盐包裹取 MD5。
返回形状是 `{source, comments, total, page, limit, maxPage}`(不是 `list`),
`filterComment` 还要处理 `atlist` 替换与 `pcontent`(楼中楼)两种形态。
"""
from __future__ import annotations
import math
import time
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2, decode_name
from app.sdk.platforms.kg.util import signature_params
_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24"
)
def _comment_params(hash_value: str, page: int, limit: int) -> str:
return (
"dfid=0&mid=16249512204336365674023395779019"
f"&clienttime={int(time.time() * 1000)}&uuid=0&extdata={hash_value}"
"&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&schash={hash_value}&clientver=11409&p={page}&clienttoken=&pagesize={limit}"
"&ver=10&kugouid=0"
)
def _replace_at(raw: str, at_list: list[dict[str, Any]]) -> str:
for item in at_list:
raw = raw.replace(f"[at={item.get('id')}]", f"@{item.get('name')} ")
return raw
def _format_comment(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
content = item.get("content") or ""
if item.get("atlist"):
content = _replace_at(str(content), item["atlist"])
images = [image.get("url") for image in item.get("images") or []] if item.get("images") else []
like = item.get("like") or {}
data = {
"id": item.get("id"),
"text": decode_name(content),
"images": images,
"location": item.get("location"),
"time": item.get("addtime"),
"timeStr": date_format2(item.get("addtime") or 0),
"userName": item.get("user_name"),
"avatar": item.get("user_pic"),
"userId": item.get("user_id"),
"likedCount": like.get("likenum"),
"replyNum": item.get("reply_num"),
"reply": [],
}
if item.get("pcontent"):
# 楼中楼:父评论包一层,原评论挂在 reply 里
result.append(
{
"id": item.get("id"),
"text": decode_name(item["pcontent"]),
"time": None,
"userName": item.get("puser"),
"avatar": None,
"userId": item.get("puser_id"),
"likedCount": None,
"replyNum": None,
"reply": [data],
}
)
else:
result.append(data)
return result
async def _get_comment(hash_value: str, page: int, limit: int, hot: bool) -> dict[str, Any]:
path = "topliked" if hot else "newest"
params = _comment_params(hash_value, page, limit)
resp = await http_fetch(
f"http://m.comment.service.kugou.com/r/v1/rank/{path}?{params}&signature={signature_params(params)}",
headers={"User-Agent": _UA},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
total = body.get("count") or 0
return {
"source": "kg",
"comments": _format_comment(body.get("list") or []),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, False)
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, True)
async def get_reply_comment(
song_info: dict[str, Any], reply_id: Any, page: int = 1, limit: int = 100
) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "")
children_id = str(song_info.get("audioId") or "").split("_")[0] if len(songmid) == 32 else songmid
resp = await http_fetch(
"http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike"
"&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&p={page}&pagesize={limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1"
f"&appid=1001&childrenid={children_id}&tid={reply_id}",
headers={"User-Agent": "Android712-AndroidPhone-8983-18-0-COMMENT-wifi"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取回复评论失败")
return {"source": "kg", "comments": _format_comment(body.get("list") or [])}
+41
View File
@@ -0,0 +1,41 @@
"""酷狗热搜(对应 sdk/kg/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
HOT_API = (
"http://gateway.kugou.com/api/v3/search/hot_tab"
"?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0"
)
HEADERS = {
"dfid": "1ssiv93oVqMp27cirf2CvoF1",
"mid": "156798703528610303473757548878786007104",
"clienttime": "1584257267",
"x-router": "msearch.kugou.com",
"user-agent": "Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi",
"kg-rc": "1",
}
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
result: list[str] = []
for item in raw_list:
for keyword in item.get("keywords") or []:
result.append(decode_name(keyword.get("keyword")))
return result
async def get_list(retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_API, method="get", headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("errcode") != 0:
raise RuntimeError("获取热搜词失败")
data = body.get("data") or {}
return {"source": "kg", "list": _filter_list(data.get("list") or [])}
+102
View File
@@ -0,0 +1,102 @@
"""酷狗 KRC 歌词解密(对应 sdk/kg/krcDecode.ts)。
密文是 base64 → 去掉 4 字节头 → 与 16 字节固定 key 循环异或 → zlib inflate。
解密后是带 `[start,duration]` 行级标签与 `<offset,duration,0>` 字级标签的 KRC 文本,
另外 `[language:<base64>]` 行里藏着 base64(JSON) 的逐行翻译,需按行号拼回 LRC。
"""
from __future__ import annotations
import base64
import json
import re
import zlib
from typing import Any
from app.sdk.common.utils import decode_name
ENC_KEY = bytes([0x40, 0x47, 0x61, 0x77, 0x5E, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2D, 0xCE, 0xD2, 0x6E, 0x69])
_HEAD_EXP = re.compile(r"^.*\[id:\$\w+\]\n")
_TRANS_EXP = re.compile(r"\[language:([\w=\\/+]+)\]")
_TRANS_LINE_EXP = re.compile(r"\[language:[\w=\\/+]+\]\n")
_LINE_EXP = re.compile(r"\[((\d+),\d+)\].*")
_WORD_TAG_EXP = re.compile(r"<(\d+,\d+),\d+>")
_PLAIN_TAG_EXP = re.compile(r"<\d+,\d+>")
def _xor_decode(data: str) -> str:
if not data:
return ""
buf = bytearray(base64.b64decode(data)[4:])
for i in range(len(buf)):
buf[i] ^= ENC_KEY[i % 16]
return zlib.decompress(bytes(buf)).decode("utf-8", errors="replace")
def _format_time(ms: int) -> str:
"""毫秒 → `mm:ss.ms`(毫秒段与原文一致不补零)。"""
minutes, rem = divmod(ms, 60000)
return f"{minutes:02d}:{rem // 1000:02d}.{ms % 1000}"
def _lrc_line(value: Any) -> str:
"""rlyric/tlyric 的单项是字符串数组(逐字),字符串形态也容忍。"""
if isinstance(value, list):
return "".join(str(item) for item in value)
return "" if value is None else str(value)
def parse_lyric(text: str) -> dict[str, str]:
text = text.replace("\r", "")
if _HEAD_EXP.match(text):
text = _HEAD_EXP.sub("", text, count=1)
rlyric: list[Any] | None = None
tlyric: list[Any] | None = None
trans = _TRANS_EXP.search(text)
if trans:
text = _TRANS_LINE_EXP.sub("", text, count=1)
try:
payload = json.loads(base64.b64decode(trans.group(1)).decode("utf-8", errors="replace"))
except ValueError:
payload = {}
for item in payload.get("content") or []:
if item.get("type") == 0:
rlyric = item.get("lyricContent")
elif item.get("type") == 1:
tlyric = item.get("lyricContent")
index = 0
def repl(match: re.Match[str]) -> str:
nonlocal index
line_match = _LINE_EXP.match(match.group(0))
time_ms = int(line_match.group(2)) if line_match else 0
stamp = _format_time(time_ms)
if rlyric is not None:
rlyric[index] = f"[{stamp}]{_lrc_line(rlyric[index]) if index < len(rlyric) else ''}"
if tlyric is not None:
tlyric[index] = f"[{stamp}]{_lrc_line(tlyric[index]) if index < len(tlyric) else ''}"
index += 1
return match.group(0).replace(match.group(1), stamp, 1)
yrc = re.sub(r"\[((\d+),\d+)\].*", repl, text)
rlyric_text = "\n".join(str(item) for item in rlyric) if rlyric else ""
tlyric_text = "\n".join(str(item) for item in tlyric) if tlyric else ""
yrc = _WORD_TAG_EXP.sub(r"<\1>", yrc)
yrc = decode_name(yrc)
lyric = _PLAIN_TAG_EXP.sub("", yrc)
return {
"lyric": lyric,
"tlyric": decode_name(tlyric_text),
"rlyric": decode_name(rlyric_text),
"yrc": yrc,
}
async def decode_krc(data: str) -> dict[str, str]:
return parse_lyric(_xor_decode(data))
__all__ = ["decode_krc", "parse_lyric"]
+110
View File
@@ -0,0 +1,110 @@
"""酷狗榜单(对应 sdk/kg/leaderboard.ts)。
榜单 id 表是硬编码的静态表(原实现 getBoards 直接返回它,不请求上游);
详情走 `mobilecdnbj.kugou.com/api/v3/rank/song`,返回的 hash 需再反查一次 SongInfo
才能拿到多档音质。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, size_formate
LIST_DETAIL_LIMIT = 100
BOARD_LIST: list[dict[str, str]] = [
{"id": "kgtop500", "name": "TOP500", "bangid": "8888"},
{"id": "kgwlhgb", "name": "网络榜", "bangid": "23784"},
{"id": "kgbsb", "name": "飙升榜", "bangid": "6666"},
{"id": "kgfxb", "name": "分享榜", "bangid": "21101"},
{"id": "kgcyyb", "name": "纯音乐榜", "bangid": "33164"},
{"id": "kggfjqb", "name": "古风榜", "bangid": "33161"},
{"id": "kgyyjqb", "name": "粤语榜", "bangid": "33165"},
{"id": "kgomjqb", "name": "欧美榜", "bangid": "33166"},
{"id": "kgdyrgb", "name": "电音榜", "bangid": "33160"},
{"id": "kgjdrgb", "name": "DJ热歌榜", "bangid": "24971"},
{"id": "kghyxgb", "name": "华语新歌榜", "bangid": "31308"},
]
RANK_API = "http://mobilecdnbj.kugou.com/api/v3/rank/song"
def get_url(page: int, bangid: str, limit: int = LIST_DETAIL_LIMIT) -> str:
return (
f"{RANK_API}?version=9108&ranktype=1&plat=0&pagesize={limit}"
f"&area_code=1&page={page}&rankid={bangid}&with_res_tag=0&show_portrait_mv=1"
)
def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "320filesize", "320hash"),
("flac", "sqfilesize", "sqhash"),
("flac24bit", "filesize_high", "hash_high"),
):
if item.get(size_key, 0) == 0:
continue
size = size_formate(item.get(size_key))
types.append({"type": quality, "size": size, "hash": item.get(hash_key)})
_types[quality] = {"size": size, "hash": item.get(hash_key)}
result.append(
{
"singer": _singer(item.get("authors")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(item.get("remark")),
"albumId": item.get("album_id"),
"songmid": item.get("audio_id"),
"source": "kg",
"interval": format_play_time(item.get("duration")),
"img": None,
"lrc": None,
"hash": item.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _singer(authors: list[dict[str, Any]] | None) -> str:
names = [str(author["author_name"]) for author in authors or [] if author.get("author_name")]
return decode_name("、".join(names))
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "kg"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
resp: Any
try:
resp = await http_fetch(get_url(page, bangid))
except Exception: # noqa: BLE001 - 可降级失败:换一次请求重试
return await get_list(bangid, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
return await get_list(bangid, page, retry_num + 1)
data = body.get("data") or {}
return {
"total": data.get("total"),
"list": _filter_data(data.get("info") or []),
"limit": 100,
"page": page,
"source": "kg",
}
def get_detail_page_url(id: Any) -> str:
value = str(id).replace("kg__", "") if isinstance(id, str) else id
return f"https://www.kugou.com/yy/rank/home/1-{value}.html"
+91
View File
@@ -0,0 +1,91 @@
"""酷狗歌词(对应 sdk/kg/lyric.ts)。
两步:`lyrics.kugou.com/search` 按歌名+hash+时长找候选 → `lyrics.kugou.com/download`
取回 base64 内容。`fmt=krc` 需走 krc_decode 解密,`fmt=lrc` 直接 base64 解码。
"""
from __future__ import annotations
import base64
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.krc_decode import decode_krc
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def get_intv(interval: Any) -> int:
"""`mm:ss` → 毫秒(原文把秒当毫秒拼给上游,这里保持一致)。"""
if not interval:
return 0
parts = str(interval).split(":")
total = 0
unit = 1
while parts:
part = parts.pop()
try:
total += int(float(part)) * unit
except ValueError:
pass
unit *= 60
return int(total)
async def _search_lyric(name: str, hash_value: str, time: Any, try_num: int = 0) -> dict[str, str] | None:
from app.sdk.platforms.kg.util import encode_uri_component
url = (
"http://lyrics.kugou.com/search?ver=1&man=yes&client=pc"
f"&keyword={encode_uri_component(name)}&hash={hash_value}&timelength={time}&lrctxt=1"
)
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
return await _search_lyric(name, hash_value, time, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
candidates = body.get("candidates") or []
if not candidates:
return None
info = candidates[0]
fmt = "krc" if info.get("krctype") == 1 and info.get("contenttype") != 1 else "lrc"
return {"id": str(info.get("id")), "accessKey": str(info.get("accesskey")), "fmt": fmt}
async def _get_lyric_download(id: str, access_key: str, fmt: str, try_num: int = 0) -> dict[str, str]:
url = f"http://lyrics.kugou.com/download?ver=1&client=pc&id={id}&accesskey={access_key}&fmt={fmt}&charset=utf8"
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
# 重试必须回到本方法(搜索接口与下载接口的入参语义不同)
return await _get_lyric_download(id, access_key, fmt, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
content = str(body.get("content") or "")
match body.get("fmt"):
case "krc":
return await decode_krc(content)
case "lrc":
return {
"lyric": base64.b64decode(content).decode("utf-8", errors="replace"),
"tlyric": "",
"rlyric": "",
"yrc": "",
}
case _:
raise RuntimeError(f"未知歌词格式: {body.get('fmt')}")
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
name = str(song_info.get("name") or "")
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
time = song_info.get("_interval") or get_intv(song_info.get("interval"))
result = await _search_lyric(name, hash_value, time)
if not result:
raise RuntimeError("Get lyric failed")
return await _get_lyric_download(result["id"], result["accessKey"], result["fmt"])
+138
View File
@@ -0,0 +1,138 @@
"""按 hash 批量反查歌曲信息(对应 sdk/kg/musicInfo.ts)。
多个模块(榜单/歌单/专辑/歌手/解析/路由兜底)都要把 hash 换成完整 SongInfo,
统一走这里:`http://gateway.kugou.com/v3/album_audio/audio`,每批最多 100 个 hash。
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.sdk.common.utils import decode_name, format_play_time, size_formate
from app.sdk.platforms.kg.util import create_http_fetch
MUSIC_INFO_URL = "http://gateway.kugou.com/v3/album_audio/audio"
MUSIC_INFO_HEADERS = {
"KG-THash": "13a3164",
"KG-RC": "1",
"KG-Fake": "0",
"KG-RF": "00869891",
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
"x-router": "kmr.service.kugou.com",
}
_FIELDS = "album_info,author_name,audio_info,ori_audio_name,base,songname,classification"
def _filter_music_info_list(raw_list: list[Any]) -> list[dict[str, Any]]:
ids: set[Any] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
if not item:
continue
audio = item.get("audio_info") or {}
if audio.get("audio_id") in ids:
continue
ids.add(audio.get("audio_id"))
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "filesize_320", "hash_320"),
("flac", "filesize_flac", "hash_flac"),
("flac24bit", "filesize_high", "hash_high"),
):
if str(audio.get(size_key, "0")) == "0":
continue
size = size_formate(int(audio.get(size_key) or 0))
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
album = item.get("album_info") or {}
result.append(
{
"singer": decode_name(item.get("author_name")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(album.get("album_name")),
"albumId": album.get("album_id"),
"songmid": audio.get("audio_id"),
"source": "kg",
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
"img": None,
"lrc": None,
"hash": audio.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _build_tasks(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
base = {
"area_code": "1",
"show_privilege": 1,
"show_album_info": "1",
"is_publish": "",
"appid": 1005,
"clientver": 11451,
"mid": "1",
"dfid": "-",
"clienttime": int(time.time() * 1000),
"key": "OIlwieks28dk2k092lksi2UIkp",
"fields": _FIELDS,
}
tasks: list[dict[str, Any]] = []
rest = list(hashs)
while rest:
tasks.append({"data": rest[:100], **base})
if len(rest) < 100:
break
rest = rest[100:]
return tasks
async def get_music_infos(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 hash 列表批量取 SongInfo(上游按 100 个一批并发,扁平化后去重)。"""
async def one(task: dict[str, Any]) -> list[Any]:
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": task, "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
return [entry[0] if entry else None for entry in items]
chunks = await asyncio.gather(*(one(task) for task in _build_tasks(hashs)))
return _filter_music_info_list([item for chunk in chunks for item in chunk])
async def get_music_info_raw(hash: str) -> Any:
tasks = _build_tasks([{"hash": hash}])
if not tasks:
return None
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": tasks[0], "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
flat = [entry[0] if entry else None for entry in items]
return flat[0] if flat else None
async def get_music_info(hash: str) -> dict[str, Any] | None:
infos = await get_music_infos([{"hash": hash}])
return infos[0] if infos else None
async def get_music_infos_by_list(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""上游返回的 {hash} 列表 → 完整 SongInfo 列表。"""
if not items:
return []
return await get_music_infos([{"hash": item.get("hash")} for item in items])
async def get_music_info_for_song_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""顶层 `get_music_info(song_info)` 的实现:hash 优先,退回 songmid。"""
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "").strip()
if not hash_value:
return None
return await get_music_info(hash_value)
+97
View File
@@ -0,0 +1,97 @@
"""酷狗搜索(对应 sdk/kg/musicSearch.ts)。"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, format_singer_name, size_formate
from app.sdk.platforms.kg.util import encode_uri_component
DEFAULT_LIMIT = 30
_SEARCH_API = "https://songsearch.kugou.com/song_search_v2"
def _filter_data(raw: dict[str, Any]) -> dict[str, Any]:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "FileSize", "FileHash"),
("320k", "HQFileSize", "HQFileHash"),
("flac", "SQFileSize", "SQFileHash"),
("flac24bit", "ResFileSize", "ResFileHash"),
):
if raw.get(size_key, 0) == 0:
continue
size = size_formate(raw.get(size_key))
types.append({"type": quality, "size": size, "hash": raw.get(hash_key)})
_types[quality] = {"size": size, "hash": raw.get(hash_key)}
return {
"singer": decode_name(format_singer_name(raw.get("Singers"), "name")),
"name": decode_name(raw.get("SongName")),
"albumName": decode_name(raw.get("AlbumName")),
"albumId": raw.get("AlbumID"),
"songmid": raw.get("Audioid"),
"source": "kg",
"interval": format_play_time(raw.get("Duration")),
"_interval": raw.get("Duration"),
"img": None,
"lrc": None,
"otherSource": None,
"hash": raw.get("FileHash"),
"types": types,
"_types": _types,
"typeUrl": {},
}
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同名同 hash 去重;`Grp` 是同一首歌的其它版本,也一并展开。"""
ids: set[str] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
key = f"{item.get('Audioid')}{item.get('FileHash')}"
if key in ids:
continue
ids.add(key)
result.append(_filter_data(item))
for child in item.get("Grp") or []:
child_key = f"{child.get('Audioid')}{child.get('FileHash')}"
if child_key in ids:
continue
ids.add(child_key)
result.append(_filter_data(child))
return result
async def search(keyword: str, page: int = 1, limit: int = DEFAULT_LIMIT, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = (
f"{_SEARCH_API}?keyword={encode_uri_component(keyword)}&page={page}&pagesize={page_size}"
"&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1"
)
try:
resp = await http_fetch(url)
except Exception: # noqa: BLE001 - 可降级失败:原实现同样重试同类请求
return await search(keyword, page, limit, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("error_code") != 0:
return await search(keyword, page, limit, retry_num + 1)
data = body.get("data") or {}
raw_list = data.get("lists")
if raw_list is None:
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(raw_list)
total = data.get("total") or 0
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"limit": page_size,
"total": total,
"source": "kg",
}
+133
View File
@@ -0,0 +1,133 @@
"""酷狗链接解析(对应 sdk/kg/parse.ts)。
歌曲:`https://www.kugou.com/song/#hash=<32位hash>`(含 &album_id= 等尾串)或裸 hash。
元数据取 `m.kugou.com/app/i/getSongInfo.php`(songName/author_name/timeLength/album_img/bitRate),
再并发合并音乐信息里的专辑与多档 hash;直链优先用 playInfo 的 url,仍为空时用 apis/kg
签名通道尽力补一次 —— 失败只返回元数据(与上游付费曲语义一致)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kg import album, playlist
from app.sdk.platforms.kg.music_info import get_music_info
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
HASH_RE = re.compile(r"hash=([a-fA-F0-9]{32})", re.IGNORECASE)
RAW_HASH_RE = re.compile(r"^[a-fA-F0-9]{32}$")
def extract_hash(link: Any) -> str:
"""链接/裸 hash → 32 位小写 hash;都不匹配返回空串。"""
raw = str(link if link is not None else "").strip()
match = HASH_RE.search(raw)
if match:
return match.group(1).lower()
return raw.lower() if RAW_HASH_RE.match(raw) else ""
async def _fetch_song_info_php(hash_value: str) -> dict[str, Any] | None:
resp = await http_fetch(
f"http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash_value}",
method="get",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return None
body = resp.body
if int(body.get("errcode") or 0) != 0 or not body.get("hash"):
return None
return body
async def parse_song(link: str) -> dict[str, Any]:
import asyncio
hash_value = extract_hash(link)
if not hash_value:
raise RuntimeError("无效的酷狗歌曲链接(未找到 32 位 hash)")
raw, info = await asyncio.gather(
_fetch_song_info_php(hash_value), get_music_info(hash_value), return_exceptions=True
)
raw = None if isinstance(raw, BaseException) else raw
info = None if isinstance(info, BaseException) else info
if not raw and not info:
raise RuntimeError("酷狗歌曲不存在或已下架")
raw = raw or {}
info = info or {}
file_name = str(raw.get("fileName") or "")
fallback_name = " - ".join(file_name.split(" - ")[1:]) if " - " in file_name else ""
name = decode_name(str(raw.get("songName") or "").strip() or fallback_name or info.get("name") or "")
singer = decode_name(str(raw.get("author_name") or "").strip() or info.get("singer") or "")
duration = int(raw.get("timeLength") or 0) or 0
song: dict[str, Any] = {
"songmid": str(info.get("songmid") or raw.get("audio_id") or "").strip(),
"hash": str(raw.get("hash") or hash_value).lower(),
"name": name,
"singer": singer,
"albumName": decode_name(str(info.get("albumName") or "").strip()),
"albumId": str(info.get("albumId") or raw.get("albumid") or raw.get("req_albumid") or "").strip(),
"interval": format_play_time(duration) if duration > 0 else (info.get("interval") or "--/--"),
"img": str(raw.get("album_img") or "").replace("{size}", "240") or info.get("img") or None,
"lrc": None,
"otherSource": None,
"types": info.get("types") or [],
"_types": info.get("_types") or {},
"typeUrl": {},
"source": "kg",
"extra": {
"hash": str(raw.get("hash") or hash_value).lower(),
"url": str(raw.get("url") or ""),
"backup_url": raw.get("backup_url") or [],
"bitrate": int(raw.get("bitRate") or 0) or 0,
"link": f"https://www.kugou.com/song/#hash={hash_value}",
},
}
# playInfo 的 url/backup_url 是官方直链(付费曲为空),优先使用
if not song.get("url"):
backup = raw.get("backup_url") or []
raw_url = str(raw.get("url") or "") or str(backup[0] if backup else "")
if raw_url:
song["url"] = raw_url
song["type"] = "320k" if (int(raw.get("bitRate") or 0) or 0) >= 320000 else "128k"
# 仍无直链时(风控/付费)用签名通道尽力补一次
if not song.get("url"):
try:
from app.sdk.apis.kg import get_music_url
hit = await get_music_url(
{
"hash": song["hash"],
"albumId": song["albumId"],
"songmid": song["songmid"],
"_types": song["_types"],
},
"128k",
)
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游付费曲无直链的失败语义一致:元数据仍返回
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)
+62
View File
@@ -0,0 +1,62 @@
"""酷狗封面(对应 sdk/kg/pic.ts)。
走 `media.store.kugou.com/v1/get_res_privilege`,从返回的 `info.image` 里
把 `{size}` 占位替换为上游给的 `imgsize[0]`。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
PIC_API = "http://media.store.kugou.com/v1/get_res_privilege"
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def _album_audio_id(song_info: dict[str, Any]) -> Any:
"""songmid 为 32 位时是旧形态(audioId_x),要取 `_` 前的段(修复历史存储变更)。"""
songmid = str(song_info.get("songmid") or "")
if len(songmid) == 32:
return str(song_info.get("audioId") or "").split("_")[0]
return song_info.get("songmid")
async def get_pic(song_info: dict[str, Any]) -> str:
body = {
"appid": 1001,
"area_code": "1",
"behavior": "play",
"clientver": "9020",
"need_hash_offset": 1,
"relate": 1,
"resource": [
{
"album_audio_id": _album_audio_id(song_info),
"album_id": song_info.get("albumId"),
"hash": song_info.get("hash"),
"id": 0,
"name": f"{song_info.get('singer')} - {song_info.get('name')}.mp3",
"type": "audio",
}
],
"token": "",
"userid": 2626431536,
"vip": 1,
}
resp = await http_fetch(PIC_API, method="POST", headers=HEADERS, body=body)
payload = resp.body if isinstance(resp.body, dict) else {}
if payload.get("error_code") != 0:
raise RuntimeError("图片获取失败")
data = payload.get("data") or [{}]
info = (data[0] or {}).get("info") or {}
image = str(info.get("image") or "")
img = image.replace("{size}", str(info["imgsize"][0])) if info.get("imgsize") else image
if not img:
raise RuntimeError("Pic get failed")
return img
+233
View File
@@ -0,0 +1,233 @@
"""酷狗歌单模块(对应 sdk/kg/playlist.ts)—— 统一形状层。
解析链复用 song_list 的能力(special/single、songlist/gcid、share chain、zlist、
global_collection_id 等),本模块只做形状归一:摘要 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg import song_list
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _playlist_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
def _to_summary(item: dict[str, Any]) -> dict[str, Any] | None:
raw_id = _first_non_empty(item.get("specialid"), item.get("global_specialid"), item.get("id"))
playlist_id = re.sub(r"^id_", "", raw_id)
if not playlist_id:
return None
play_count = _finite(item.get("playcount"))
return {
"id": playlist_id,
"name": _first_non_empty(item.get("specialname"), item.get("name")),
"cover": _playlist_cover(_first_non_empty(item.get("imgurl"), item.get("img"))),
"trackCount": int(item.get("songcount") or item.get("total") or 0) or 0,
"playCount": play_count if play_count is not None else (item.get("play_count") or 0),
"creator": _first_non_empty(item.get("nickname"), item.get("username"), item.get("author")),
"description": _first_non_empty(item.get("intro"), item.get("desc")),
"publishTime": str(item.get("publishtime") or item.get("time") or "").strip(),
"link": f"https://www.kugou.com/yy/special/single/{playlist_id}.html",
"source": "kg",
}
def _finite(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number and number not in (float("inf"), float("-inf")) else None
def _normalize_playlist_id(id: Any) -> str:
"""数字 id / id_ 前缀 / 链接 → song_list.get_list_detail 可识别的入参。"""
raw = str(id or "").strip()
if not raw:
return ""
return f"id_{raw}" if re.fullmatch(r"\d+", raw) else raw
def _parse_category_id(category_id: Any) -> tuple[str, str]:
parts = str(category_id or "").strip().split(":")
return str(parts[0] if parts else "").strip(), (str(parts[1]).strip() if len(parts) > 1 else "") or "0"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
res = await song_list.search(word, page, limit)
items = [summary for summary in (_to_summary(item) for item in res.get("list") or []) if summary]
return {
"source": "kg",
"list": items,
"page": page,
"limit": limit,
"total": int(res.get("total") or 0) or len(items),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
request_id = _normalize_playlist_id(playlist_id)
if not request_id:
raise RuntimeError("酷狗歌单 id 为空")
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
res = await song_list.get_list_detail(request_id, page_no)
if not res:
raise RuntimeError("酷狗歌单不存在或解析失败")
songs: list[dict[str, Any]] = res.get("list") or []
total = int(res.get("total") or 0) or len(songs)
if len(songs) > page_size:
start = (page_no - 1) * page_size
songs = songs[start : start + page_size]
info = res.get("info") or {}
return {
"source": "kg",
"info": {
"name": _first_non_empty(info.get("name"), str(playlist_id)),
"img": _playlist_cover(info.get("img")),
"desc": _first_non_empty(info.get("desc")),
"author": _first_non_empty(info.get("author")),
"play_count": info.get("play_count") or "",
},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
res = await song_list.get_list_detail(_normalize_playlist_id(playlist_id), 1)
return (res or {}).get("list") or []
async def get_categories() -> dict[str, Any]:
resp = await http_fetch(
"http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗歌单分类获取失败"))
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
for group in (body.get("data") or {}).get("info") or []:
group_name = str(group.get("name") or "").strip()
group_id = int(group.get("id") or 0) or 0
if group_id > 0 and group_name:
items.append({"id": f"{group_id}:0", "name": group_name, "group": group_name})
for child in group.get("children") or []:
child_id = int(child.get("id") or 0) or 0
child_name = str(child.get("name") or "").strip()
if not child_id or not child_name:
continue
tag_id = int(child.get("special_tag_id") or 0) or 0
items.append(
{
"id": f"{child_id}:{tag_id}",
"name": child_name,
"group": group_name,
"hot": int(child.get("is_hot") or 0) == 1,
}
)
return {"source": "kg", "list": items}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
category, tag_id = _parse_category_id(category_id)
if not category:
listing = await get_categories()
first = next((item for item in listing["list"] if item.get("id")), None)
if not first:
raise RuntimeError("酷狗歌单分类为空")
category, tag_id = _parse_category_id(first["id"])
api_url = "http://mobilecdnbj.kugou.com/api/v3/tag/specialList?" + "&".join(
["plat=0", f"page={page_no}", f"tagid={tag_id}", f"pagesize={page_size}", "ugc=1", f"id={category}", "sort=2"]
)
resp = await http_fetch(api_url, headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗分类歌单获取失败"))
items: list[dict[str, Any]] = []
for entry in (body.get("data") or {}).get("info") or []:
summary = _to_summary(entry) or _to_summary({**entry, "specialid": entry.get("global_specialid")})
if not summary:
continue
summary["categoryId"] = f"{category}:{tag_id}"
items.append(summary)
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int((body.get("data") or {}).get("total") or 0) or len(items),
}
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 30))
resp = await http_fetch(
f"http://m.kugou.com/plist/index&json=true&page={page_no}",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError("酷狗推荐歌单获取失败")
plist = (body.get("plist") or {}).get("list") or {}
items = [summary for summary in (_to_summary(item) for item in plist.get("info") or []) if summary]
if len(items) > page_size:
items = items[:page_size]
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int(plist.get("total") or 0) or len(items),
}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""special/single/<id>.html 与 songlist/<gcid_xxx>/ 优先,其余交给 song_list 通用解析。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的酷狗歌单链接")
special = re.search(r"special/single/(\d+)\.html", raw)
if special:
return await get_playlist_detail(special.group(1), 1)
gcid = re.search(r"songlist/(gcid_[A-Za-z0-9]+)", raw)
if gcid:
return await get_playlist_detail(gcid.group(1), 1)
return await get_playlist_detail(raw, 1)
+85
View File
@@ -0,0 +1,85 @@
"""酷狗歌手(对应 sdk/kg/singer.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch
SINGER_INFO_URL = "http://mobiles.kugou.com/api/v5/singer/info"
SINGER_ALBUM_URL = "http://mobiles.kugou.com/api/v5/singer/album"
SINGER_SONG_URL = "http://mobiles.kugou.com/api/v5/singer/song"
def _check_id(id: Any) -> None:
# kg 源有些歌曲在歌手未被酷狗收录时返回的歌手 id 为 0
if str(id) in ("0", ""):
raise RuntimeError("歌手不存在")
async def get_info(id: str) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_INFO_URL}?singerid={id}")
body = body or {}
if not body:
raise RuntimeError("get singer info faild.")
imgurl = str(body.get("imgurl") or "")
return {
"source": "kg",
"id": body.get("singerid"),
"info": {
"name": body.get("singername"),
"desc": body.get("intro"),
"avatar": imgurl.replace("{size}", "480") if imgurl else "",
"gender": "man" if body.get("grade") == 1 else "woman",
},
"count": {"music": body.get("songcount"), "album": body.get("albumcount")},
}
def _filter_album_list(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"id": item.get("albumid"),
"count": item.get("songcount"),
"info": {
"name": item.get("albumname"),
"author": item.get("singername"),
"img": str(item.get("imgurl") or item.get("img") or "").replace("{size}", "480"),
"desc": item.get("intro"),
},
}
for item in raw
]
async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_ALBUM_URL}?singerid={id}&page={page}&pagesize={limit}")
body = body or {}
if not body.get("info"):
raise RuntimeError("get singer album list faild.")
return {
"source": "kg",
"list": _filter_album_list(body.get("info") or []),
"limit": limit,
"page": page,
"total": body.get("total"),
}
async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_SONG_URL}?singerid={id}&page={page}&pagesize={limit}")
body = body or {}
if not body.get("info"):
raise RuntimeError("get singer song list faild.")
songs = await get_music_infos_by_list(body.get("info") or [])
return {
"source": "kg",
"list": songs,
"limit": limit,
"page": page,
"total": body.get("total"),
}
+844
View File
@@ -0,0 +1,844 @@
"""酷狗歌单广场与歌单解析(对应 sdk/kg/songList.ts,31KB 的最大模块)。
三个入口:
get_list(sort_id, tag_id, page) —— 歌单广场(special/getSpecial + 详情 + 推荐)
get_list_detail(id, page) —— 歌单解析,覆盖 special/single、酷狗码、gcid、
share chain、zlist、global_collection_id、网页链接
search(keyword, page, limit) —— 歌单搜索(msearchretry v3 search/special)
原实现的 `_requestObj*` 取消句柄在 Python 侧无对应物(http_fetch 自身不可取消),
已省略;其余降级链、重试次数、错误文案与原实现保持一致。
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, size_formate, to_md5
from app.sdk.platforms.kg.util import create_http_fetch, signature_params
LIST_DETAIL_LIMIT = 10000
SORT_LIST: list[dict[str, str]] = [
{"name": "推荐", "id": "5"},
{"name": "最热", "id": "6"},
{"name": "最新", "id": "7"},
{"name": "热藏", "id": "3"},
{"name": "飙升", "id": "8"},
]
_LIST_DATA_EXP = re.compile(r"global\.data = (\[.+\]);")
_LIST_INFO_EXP = re.compile(r'global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};')
_LIST_DETAIL_LINK_EXP = re.compile(r"^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)")
# chain → 分享信息缓存(原实现是 Map,跨请求复用同一进程缓存)
_CACHE: dict[str, Any] = {}
_CURRENT_TAG_INFO: dict[str, Any] = {"id": None, "info": None}
def _handle_signature(id: Any, page: int, limit: int) -> str:
"""H5 签名:业务参数按键排序拼接后以 key 包裹取 MD5(与 signature_params 同构)。"""
params = {
"srcappid": "2919",
"clientver": "20000",
"clienttime": int(time.time() * 1000),
"mid": "0",
"uuid": "0",
"dfid": "-",
"appid": 1058,
"type": 0,
"module": "playlist",
"page": page,
"pagesize": limit,
"specialid": id,
}
key = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt"
sorted_params = "".join(f"{k}={params[k]}" for k in sorted(params))
return to_md5(f"{key}{sorted_params}{key}")
def parse_html_desc(html: str) -> str | None:
prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
index = html.find(prefix)
if index < 0:
return None
after = html[index + len(prefix) :]
end = after.find("</div>")
if end < 0:
return None
return decode_name(after[:end])
def _filter_info_hot_tag(raw: dict[str, Any]) -> list[dict[str, Any]]:
if raw.get("status") != 1:
return []
return [
{"id": tag.get("special_id"), "name": tag.get("special_name"), "source": "kg"}
for tag in (raw.get("data") or {}).values()
]
def _filter_tag_info(raw: dict[str, Any]) -> list[dict[str, Any]]:
return [
{
"name": name,
"list": [
{
"parent_id": tag.get("parent_id"),
"parent_name": tag.get("pname"),
"id": tag.get("id"),
"name": tag.get("name"),
"source": "kg",
}
for tag in (raw[name].get("data") or [])
],
}
for name in raw
]
def _filter_list(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"play_count": item.get("total_play_count") or format_play_count(item.get("play_count")),
"id": "id_" + str(item.get("specialid")),
"author": item.get("nickname"),
"name": item.get("specialname"),
"time": date_format(item.get("publish_time") or item.get("publishtime"), "Y-M-D"),
"img": item.get("img") or item.get("imgurl"),
"total": item.get("songcount"),
"grade": item.get("grade"),
"desc": item.get("intro"),
"source": "kg",
}
for item in raw_data
]
def _create_task_body(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
base = {
"area_code": "1",
"show_privilege": 1,
"show_album_info": "1",
"is_publish": "",
"appid": 1005,
"clientver": 11451,
"mid": "1",
"dfid": "-",
"clienttime": int(time.time() * 1000),
"key": "OIlwieks28dk2k092lksi2UIkp",
"fields": "album_info,author_name,audio_info,ori_audio_name,base,songname",
}
tasks: list[dict[str, Any]] = []
rest = list(hashs)
while rest:
tasks.append({"data": rest[:100], **base})
if len(rest) < 100:
break
rest = rest[100:]
return tasks
async def _create_task(hashs: list[dict[str, Any]]) -> list[list[Any]]:
url = "http://gateway.kugou.com/v2/album_audio/audio"
headers = {
"KG-THash": "13a3164",
"KG-RC": "1",
"KG-Fake": "0",
"KG-RF": "00869891",
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
"x-router": "kmr.service.kugou.com",
}
async def one(task: dict[str, Any]) -> list[Any]:
data = await create_http_fetch(url, {"method": "POST", "body": task, "headers": headers})
items = data if isinstance(data, list) else []
return [entry[0] if entry else None for entry in items]
return await asyncio.gather(*(one(task) for task in _create_task_body(hashs)))
def _de_duplication(datas: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[Any] = set()
result: list[dict[str, Any]] = []
for item in datas:
hash_value = item.get("hash")
if hash_value in seen:
continue
seen.add(hash_value)
result.append(item)
return result
async def _get_music_infos(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""hash 列表 → SongInfo(走 v2 album_audio 批量接口,与 musicInfo.ts 的 v3 不同)。"""
deduped = _de_duplication(items)
if not deduped:
return []
chunks = await _create_task([{"hash": item.get("hash")} for item in deduped])
return _filter_data2([entry for chunk in chunks for entry in chunk])
def _filter_data2(raw_list: list[Any]) -> list[dict[str, Any]]:
ids: set[Any] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
if not item:
continue
audio = item.get("audio_info") or {}
if audio.get("audio_id") in ids:
continue
ids.add(audio.get("audio_id"))
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "filesize_320", "hash_320"),
("flac", "filesize_flac", "hash_flac"),
("flac24bit", "filesize_high", "hash_high"),
):
if str(audio.get(size_key, "0")) == "0":
continue
size = size_formate(int(audio.get(size_key) or 0))
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
album = item.get("album_info") or {}
result.append(
{
"singer": decode_name(item.get("author_name")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(album.get("album_name")),
"albumId": album.get("album_id"),
"songmid": audio.get("audio_id"),
"source": "kg",
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
"img": None,
"lrc": None,
"hash": audio.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
async def get_list_detail_by_special_id(id: Any, page: int | None = None, try_num: int = 0) -> dict[str, Any]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_song_list_detail_url(id))
html = resp.body if isinstance(resp.body, str) else ""
list_data = _LIST_DATA_EXP.search(html)
list_info = _LIST_INFO_EXP.search(html)
if not list_data:
return await get_list_detail_by_special_id(id, page, try_num + 1)
songs = await _get_music_infos(json.loads(list_data.group(1)))
name = list_info.group(1) if list_info else None
pic = list_info.group(2) if list_info else None
return {
"list": songs,
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {"name": name, "img": pic, "desc": parse_html_desc(html)},
}
def get_info_url(tag_id: Any = None) -> str:
if tag_id:
return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c={tag_id}"
return "http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&"
def get_song_list_url(sort_id: Any, tag_id: Any, page: int) -> str:
tag = "" if tag_id is None else tag_id
return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t={sort_id}&c={tag}&p={page}"
def get_song_list_detail_url(id: Any) -> str:
return f"http://www2.kugou.kugou.com/yueku/v9/special/single/{id}-5-9999.html"
async def _get_song_list(sort_id: Any, tag_id: Any, page: int, try_num: int = 0) -> list[dict[str, Any]]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_song_list_url(sort_id, tag_id, page))
body = resp.body if isinstance(resp.body, dict) else {}
if not body or body.get("status") != 1:
return await _get_song_list(sort_id, tag_id, page, try_num + 1)
return _filter_list(body.get("special_db") or [])
async def _get_song_list_recommend(try_num: int = 0) -> list[dict[str, Any]]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
"http://everydayrec.service.kugou.com/guess_special_recommend",
method="post",
headers={"User-Agent": "KuGou2012-8275-web_browser_event_handler"},
body={
"appid": 1001,
"clienttime": 1566798337219,
"clientver": 8275,
"key": "f1f93580115bb106680d2375f8032d96",
"mid": "21511157a05844bd085308bc76ef3343",
"platform": "pc",
"userid": "262643156",
"return_min": 6,
"return_max": 15,
},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
return await _get_song_list_recommend(try_num + 1)
return _filter_list((body.get("data") or {}).get("special_list") or [])
async def _get_list_info(tag_id: Any, try_num: int = 0) -> dict[str, Any]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_info_url(tag_id))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
return await _get_list_info(tag_id, try_num + 1)
params = (body.get("data") or {}).get("params") or {}
return {
"limit": params.get("pagesize"),
"page": params.get("p"),
"total": params.get("total"),
"source": "kg",
}
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
"""歌单广场:首屏(无标签、第 1 页、默认排序)额外混入推荐歌单。"""
tasks: list[Any] = [_get_song_list(sort_id, tag_id, page)]
if _CURRENT_TAG_INFO["id"] == tag_id:
tasks.append(asyncio.sleep(0, result=_CURRENT_TAG_INFO["info"]))
else:
async def load_info() -> dict[str, Any]:
info = await _get_list_info(tag_id)
_CURRENT_TAG_INFO["id"] = tag_id
_CURRENT_TAG_INFO["info"] = dict(info)
return info
tasks.append(load_info())
if not tag_id and page == 1 and sort_id == SORT_LIST[0]["id"]:
tasks.append(_get_song_list_recommend())
results = await asyncio.gather(*tasks)
songs: list[dict[str, Any]] = results[0]
info: dict[str, Any] = results[1]
if len(results) > 2 and results[2]:
songs = [*results[2], *songs]
return {"list": songs, **info}
async def get_tags(try_num: int = 0) -> dict[str, Any]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_info_url())
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
return await get_tags(try_num + 1)
data = body.get("data") or {}
return {
"hotTag": _filter_info_hot_tag(data.get("hotTag") or {}),
"tags": _filter_tag_info(data.get("tagids") or {}),
"source": "kg",
}
def get_detail_page_url(id: Any) -> str:
value = id
if isinstance(value, str):
if re.search(r"^https?://", value):
return value
value = value.replace("id_", "")
return f"https://www.kugou.com/yy/special/single/{value}.html"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
from app.sdk.platforms.kg.util import encode_uri_component
url = (
f"http://msearchretry.kugou.com/api/v3/search/special?keyword={encode_uri_component(keyword)}"
f"&page={page}&pagesize={limit}&showtype=10&filter=0&version=7910&sver=2"
)
resp = await http_fetch(url)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
raise RuntimeError("filed")
data = body.get("data") or {}
return {
"list": [
{
"play_count": format_play_count(item.get("playcount")),
"id": "id_" + str(item.get("specialid")),
"author": item.get("nickname"),
"name": item.get("specialname"),
"time": date_format(item.get("publishtime"), "Y-M-D"),
"img": item.get("imgurl"),
"grade": item.get("grade"),
"desc": item.get("intro"),
"total": item.get("songcount"),
"source": "kg",
}
for item in data.get("info") or []
],
"limit": limit,
"total": data.get("total"),
"page": page,
"source": "kg",
}
# ---- 歌单详情解析链 ----
async def _decode_gcid(gcid: str) -> str | None:
params = "dfid=-&appid=1005&mid=0&clientver=20109&clienttime=640612895&uuid=-"
body = {"ret_info": 1, "data": [{"id": gcid, "id_type": 2}]}
result = await create_http_fetch(
f"https://t.kugou.com/v1/songlist/batch_decode?{params}"
f"&signature={signature_params(params, 'android', json.dumps(body, separators=(',', ':')))}",
{
"method": "POST",
"headers": {
"User-Agent": (
"Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36"
),
"Referer": "https://m.kugou.com/",
},
"body": body,
},
)
items = (result or {}).get("list") or []
return items[0].get("global_collection_id") if items else None
async def _get_user_list_detail_2(global_collection_id: str) -> dict[str, Any]:
if len(global_collection_id) > 1000:
raise RuntimeError("get list error")
params = (
"appid=1058&specialid=0&global_specialid="
+ global_collection_id
+ "&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519"
"&mid=1586163242519&uuid=1586163242519&dfid=-"
)
headers = {
"mid": "1586163242519",
"Referer": "https://m3ws.kugou.com/share/index.php",
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
),
"dfid": "-",
"clienttime": "1586163242519",
}
info = await create_http_fetch(
f"https://mobiles.kugou.com/api/v5/special/info_v2?{params}&signature={signature_params(params, 'web')}",
{"headers": headers},
)
info = info or {}
total = info.get("songcount") or 0
tasks: list[Any] = []
page = 0
while total:
limit = 300 if total > 300 else total
total -= limit
page += 1
song_params = (
f"appid=1058&global_specialid={global_collection_id}&specialid=0&plat=0&version=8000"
f"&page={page}&pagesize={limit}&srcappid=2919&clientver=20000"
"&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-"
)
tasks.append(
create_http_fetch(
f"https://mobiles.kugou.com/api/v5/special/song_v2?{song_params}"
f"&signature={signature_params(song_params, 'web')}",
{
"headers": {
"mid": "1586163263991",
"Referer": "https://m3ws.kugou.com/share/index.php",
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
),
"dfid": "-",
"clienttime": "1586163263991",
}
},
)
)
song_rows: list[Any] = []
for chunk in await asyncio.gather(*tasks):
song_rows.extend((chunk or {}).get("info") or [])
songs = await _get_music_infos(song_rows)
img = info.get("imgurl")
return {
"list": songs,
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {
"name": info.get("specialname"),
"img": img.replace("{size}", "240") if img else None,
"desc": info.get("intro"),
"author": info.get("nickname"),
"play_count": format_play_count(info.get("playcount")),
},
}
async def _get_list_info_by_chain(chain: str) -> Any:
if chain in _CACHE:
return _CACHE[chain]
resp = await http_fetch(
f"https://m.kugou.com/share/?chain={chain}&id={chain}",
headers={
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
},
)
html = resp.body if isinstance(resp.body, str) else ""
match = re.search(r"var\sphpParam\s=\s({.+?});", html)
result = json.loads(match.group(1)) if match else None
_CACHE[chain] = result
return result
async def _get_user_list_detail_by_pc_chain(chain: str) -> list[dict[str, Any]]:
resp = await http_fetch(
f"http://www.kugou.com/share/{chain}.html",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
)
},
)
html = resp.body if isinstance(resp.body, str) else ""
match = re.search(r"var\sdataFromSmarty\s=\s(\[.+?\])", html)
result = json.loads(match.group(1)) if match else []
return await _get_music_infos(result)
async def _get_user_list_detail_by_id(id: Any, page: int, limit: int) -> list[dict[str, Any]]:
signature = _handle_signature(id, page, limit)
info = await create_http_fetch(
"https://pubsongscdn.kugou.com/v2/get_other_list_file?"
f"srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist"
f"&page={page}&pagesize={limit}&specialid={id}&signature={signature}",
{
"headers": {
"Referer": "https://m3ws.kugou.com/share/index.php",
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
),
"dfid": "-",
}
},
)
return await _get_music_infos((info or {}).get("info") or [])
async def _get_user_list_detail_4(song_info: dict[str, Any], chain: str, page: int) -> dict[str, Any]:
limit = 100
list_info, songs = await asyncio.gather(
_get_list_info_by_chain(chain),
_get_user_list_detail_by_id(song_info.get("id"), page, limit),
)
list_info = list_info or {}
img = list_info.get("imgurl")
return {
"list": songs or [],
"page": page,
"limit": limit,
"total": len(songs or []),
"source": "kg",
"info": {
"name": list_info.get("specialname"),
"img": img.replace("{size}", "240") if img else None,
"author": list_info.get("nickname"),
},
}
async def _get_user_list_detail_5(chain: str) -> dict[str, Any]:
list_info, songs = await asyncio.gather(_get_list_info_by_chain(chain), _get_user_list_detail_by_pc_chain(chain))
list_info = list_info or {}
img = list_info.get("imgurl")
return {
"list": songs or [],
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs or []),
"source": "kg",
"info": {
"name": list_info.get("specialname"),
"img": img.replace("{size}", "240") if img else None,
"author": list_info.get("nickname"),
},
}
async def _get_user_list_detail_3(chain: str, page: int) -> dict[str, Any]:
song_info = await create_http_fetch(
f"http://m.kugou.com/schain/transfer?pagesize={LIST_DETAIL_LIMIT}&chain={chain}"
f"&su=1&page={page}&n=0.7928855356604456",
{
"headers": {
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
)
}
},
)
song_info = song_info or {}
if not song_info.get("list"):
if song_info.get("global_collection_id"):
return await _get_user_list_detail_2(song_info["global_collection_id"])
try:
return await _get_user_list_detail_4(song_info, chain, page)
except Exception: # noqa: BLE001 - 降级到 PC 分享页
return await _get_user_list_detail_5(chain)
songs = await _get_music_infos(song_info.get("list") or [])
info = song_info.get("info") or {}
return {
"list": songs,
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {"name": info.get("name"), "img": info.get("img"), "author": info.get("username")},
}
async def _get_user_list_detail_by_code(id: str) -> dict[str, Any]:
song_info = await create_http_fetch(
"http://t.kugou.com/command/",
{
"method": "POST",
"headers": {
"KG-RC": 1,
"KG-THash": "network_super_call.cpp:3676261689:379",
"User-Agent": "",
},
"body": {
"appid": 1001,
"clientver": 9020,
"mid": "21511157a05844bd085308bc76ef3343",
"clienttime": 640612895,
"key": "36164c4015e704673c588ee202b9ecb8",
"data": id,
},
},
)
song_info = song_info or {}
info = song_info.get("info") or {}
# type 1 单曲 / 2 歌单 / 3 电台 / 4 酷狗码 / 5 别人的播放队列
if info.get("type") == 2 and not info.get("global_collection_id"):
return await get_list_detail_by_special_id(info.get("id"))
if info.get("global_collection_id"):
return await _get_user_list_detail_2(info["global_collection_id"])
song_list: Any = None
if info.get("userid") is not None:
song_list = await create_http_fetch(
"http://www2.kugou.kugou.com/apps/kucodeAndShare/app/",
{
"method": "POST",
"headers": {
"KG-RC": 1,
"KG-THash": "network_super_call.cpp:3676261689:379",
"User-Agent": "",
},
"body": {
"appid": 1001,
"clientver": 9020,
"mid": "21511157a05844bd085308bc76ef3343",
"clienttime": 640612895,
"key": "36164c4015e704673c588ee202b9ecb8",
"data": {
"id": info.get("id"),
"type": 3,
"userid": info.get("userid"),
"collect_type": 0,
"page": 1,
"pagesize": info.get("count"),
},
},
},
)
rows = song_list or song_info.get("list") or []
songs = await _get_music_infos(rows)
img_size = info.get("img_size")
return {
"list": songs,
"page": 1,
"limit": info.get("count"),
"total": len(songs),
"source": "kg",
"info": {
"name": info.get("name"),
"img": img_size.replace("{size}", "240") if img_size else info.get("img"),
"author": info.get("username"),
},
}
async def _get_user_list_detail_by_link(body: dict[str, Any], link: str) -> dict[str, Any]:
list_info = (body.get("info") or {}).get("0") or {}
total = list_info.get("count") or 0
tasks: list[Any] = []
page = 0
while total:
limit = 90 if total > 90 else total
total -= limit
page += 1
url = re.sub(r"pagesize=\d+", f"pagesize={limit}", link)
url = re.sub(r"page=\d+", f"page={page}", url)
tasks.append(
create_http_fetch(
url,
{
"headers": {
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
),
"Referer": link,
}
},
)
)
rows: list[Any] = []
for chunk in await asyncio.gather(*tasks):
rows.extend(((chunk or {}).get("list") or {}).get("info") or [])
songs = await _get_music_infos(rows)
pic = list_info.get("pic")
return {
"list": songs,
"page": page,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {
"name": list_info.get("name"),
"img": pic.replace("{size}", "240") if pic else None,
"author": list_info.get("list_create_username"),
},
}
async def _get_user_list_detail(link: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("link try max num")
if "#" in link:
link = re.sub(r"#.*$", "", link)
if "global_collection_id" in link:
return await _get_user_list_detail_2(re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", link))
if "gcid_" in link:
match = re.search(r"gcid_\w+", link)
if match:
gcid_id = await _decode_gcid(match.group(0))
if gcid_id:
return await _get_user_list_detail_2(gcid_id)
if "chain=" in link:
return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", link), page)
if ".html" in link:
if "zlist.html" in link:
link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", link)
if "pagesize" in link:
link = link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace("page=1", f"page={page}")
else:
link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}"
elif "song.html" not in link:
return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", link), page)
resp = await http_fetch(
link,
headers={
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
),
"Referer": link,
},
)
location = resp.header("location") or None
if resp.status_code > 400:
return await _get_user_list_detail(link, page, retry_num + 1)
if location:
if "global_collection_id" in location:
return await _get_user_list_detail_2(
re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", location)
)
if "gcid_" in location:
match = re.search(r"gcid_\w+", link)
if match:
gcid_id = await _decode_gcid(match.group(0))
if gcid_id:
return await _get_user_list_detail_2(gcid_id)
if "chain=" in location:
return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", location), page)
if ".html" in location:
if "zlist.html" in location:
next_link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", location)
if "pagesize" in next_link:
next_link = next_link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace(
"page=1", f"page={page}"
)
else:
next_link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}"
return await _get_user_list_detail(next_link, page, retry_num + 1)
return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", location), page)
return await _get_user_list_detail(location, page, retry_num + 1)
if isinstance(resp.body, str):
body_text = resp.body
global_id = re.search(r'"global_collection_id":"(\w+)"', body_text)
gcid_id = global_id.group(1) if global_id else None
if not gcid_id:
match = re.search(r'"encode_gic":"(\w+)"', body_text) or re.search(r'"encode_src_gid":"(\w+)"', body_text)
if match:
gcid_id = await _decode_gcid(match.group(1))
if not gcid_id:
raise RuntimeError("get list error")
return await _get_user_list_detail_2(gcid_id)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
return await _get_user_list_detail(link, page, retry_num + 1)
return await _get_user_list_detail_by_link(body, link)
async def get_list_detail(id: Any, page: int | None = None) -> dict[str, Any] | None:
"""歌单详情入口:按 id 形态分派到特殊页 / 链接 / 酷狗码。"""
value = str(id)
page_no = page or 1
if "special/single/" in value:
value = _LIST_DETAIL_LINK_EXP.sub(r"\1", value)
elif re.search(r"https?:", value):
# 修复形如 https://www.kugou.com/songlist/xxx/?uid=xxx&cover=http%3A%2F%2F... 的脏链接
return await _get_user_list_detail(re.sub(r"^.*?http", "http", value), page_no)
elif re.fullmatch(r"\d+", value):
return await _get_user_list_detail_by_code(value)
elif value.startswith("id_"):
value = value.replace("id_", "")
return await get_list_detail_by_special_id(value, page_no)
+107
View File
@@ -0,0 +1,107 @@
"""酷狗通用请求封装与签名(对应 sdk/kg/util.ts + sdk/auth/kg.ts 的设备/签名部分)。
`app/sdk/auth/kg.py` 仍是登录流程的 stub(禁止修改),因此设备指纹与 android/web
签名在这里单独实现:取流(apis/kg.py)与取流兜底都要用同一套指纹,放这里避免重复。
"""
from __future__ import annotations
import hashlib
import uuid
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import to_md5
ANDROID_SALT = "OIlwieks28dk2k092lksi2UIkp"
WEB_SALT = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt"
_RANDOM_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
def signature_params(params: str, platform: str = "android", body: str = "") -> str:
"""按 key 排序拼接后以盐包裹取 MD5(android 与 web 只是盐不同)。"""
key = WEB_SALT if platform == "web" else ANDROID_SALT
items = params.split("&")
items.sort()
return to_md5(f"{key}{''.join(items)}{body}{key}")
def android_sign(params: dict[str, Any], data: str = "") -> str:
"""网关签名:`${ANDROID_SALT}排序后的k=v拼接${data}${ANDROID_SALT}` 的 MD5。"""
import json
parts = []
for key in sorted(params):
value = params[key]
# JSON.stringify 是紧凑格式(无空格),登录/注册签名依赖逐字节一致
encoded = (
json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, (dict, list)) else value
)
parts.append(f"{key}={encoded}")
return to_md5(f"{ANDROID_SALT}{''.join(parts)}{data}{ANDROID_SALT}")
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def random_string(n: int) -> str:
import random
return "".join(_RANDOM_CHARS[random.randrange(len(_RANDOM_CHARS))] for _ in range(n))
def pkcs7_pad(data: bytes, block: int = 16) -> bytes:
"""PKCS#7 填充 —— Node 的 createCipheriv 会自动补,cryptography 不会。"""
remain = block - (len(data) % block)
return data + bytes([remain]) * remain
def pkcs7_unpad(data: bytes, block: int = 16) -> bytes:
"""PKCS#7 去填充(对应 Node createDecipheriv 的自动 unpad)。"""
if not data:
return data
remain = data[-1]
if remain < 1 or remain > block or len(data) < remain:
return data
return data[:-remain]
def get_device() -> dict[str, Any]:
"""设备指纹持久化:guid/mid/dfid 稳定复用(取流与登录共用同一指纹)。"""
dev = get_credentials("_kg_device")
if not dev:
dev = {"guid": str(uuid.uuid4()), "mid": "", "dfid": random_string(24)}
if not dev.get("mid"):
dev["mid"] = str(int(hashlib.md5(str(dev.get("guid") or "").encode()).hexdigest(), 16))
set_credentials("_kg_device", dev)
if not dev.get("dfid"):
dev["dfid"] = random_string(24)
return dev
async def create_http_fetch(url: str, options: dict[str, Any] | None = None, retry_num: int = 0) -> Any:
"""带重试的请求:非 200 或 errcode 非 0 一律重试(最多 3 次)。
成功时按原实现的取值优先级返回:body.data → body.info(数组时返回整 body) → body.info。
"""
if retry_num > 2:
raise RuntimeError("try max num")
try:
result = await http_fetch(url, **(options or {}))
except Exception as err: # noqa: BLE001 - 可降级失败:换一次请求重试
print(f"[kg] 请求失败将重试({retry_num + 1}/3) {url}: {err}")
return await create_http_fetch(url, options, retry_num + 1)
body = result.body if isinstance(result.body, dict) else {}
err_code = body.get("error_code", body.get("errcode", body.get("err_code")))
if result.status_code != 200 or err_code != 0:
return await create_http_fetch(url, options, retry_num + 1)
if body.get("data"):
return body["data"]
if isinstance(body.get("info"), list):
return body
return body.get("info")
+95
View File
@@ -0,0 +1,95 @@
"""酷我音乐(对应 sdk/kw/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 专辑 / 歌单 / 解析,
顶层挂 get_music_url(取流在 apis/kw.py)、get_lyric、get_pic、get_music_info、
handle_music_info、get_music_detail_page_url。
酷我没有歌手主页子模块(TS 侧也没有),`singer` 能力位留空,路由据此返回 404。
"""
from __future__ import annotations
from typing import Any
from app.sdk.apis.kw import get_music_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms._base import Platform
from app.sdk.platforms.kw import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_search,
parse,
pic,
playlist,
song_list,
)
from app.sdk.platforms.kw.util import format_singer
MUSIC_INFO_API = "http://www.kuwo.cn/api/www/music/musicInfo?mid={songmid}"
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""歌曲详情(歌名/歌手/封面/专辑)。上游 code != 200 时抛出 msg 原文。"""
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(MUSIC_INFO_API.format(songmid=songmid))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") == 200:
return body.get("data") or {}
raise RuntimeError(str(body.get("msg") or "获取歌曲信息失败"))
async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""用 get_music_info 就地补全 name / singer / img / albumName,与原实现一致。"""
info = await get_music_info(song_info)
song_info["name"] = info.get("name")
song_info["singer"] = format_singer(str(info.get("artist") or ""))
song_info["img"] = info.get("pic")
song_info["albumName"] = info.get("album")
return song_info
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"http://www.kuwo.cn/play_detail/{song_info.get('songmid') or ''}"
async def get_music_urls(music_info: dict[str, Any]) -> dict[str, str]:
"""批量取各档直链,返回 `{type: url}`(原 getMusicUrls 的回调语义)。"""
import asyncio
types = music_info.get("types") or []
songmid = str(music_info.get("songmid") or "")
results = await asyncio.gather(
*(get_music_url({"songmid": songmid}, str(item.get("type") or "")) for item in types),
return_exceptions=True,
)
type_url: dict[str, str] = {}
for res in results:
if isinstance(res, BaseException):
continue
if res.get("url"):
type_url[str(res.get("type"))] = str(res["url"])
return type_url
MODULE = Platform(
id="kw",
name="酷我音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_music_urls=get_music_urls,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=get_music_info,
handle_music_info=handle_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+251
View File
@@ -0,0 +1,251 @@
"""酷我专辑(对应 sdk/kw/album.ts)。
上游 `search.kuwo.cn/r.s?stype=albuminfo` 返回伪 JSON(单引号),服务端固定
1000 条/页。对外两个入口:
* `get_album_list_detail(id, page)` —— 单页直取,第三参数是 retry_num
* `get_album_detail(id, page, limit)` —— 统一形状,limit 小于 1000 时本地开窗
路由按 hasattr 分流(`app/routes/music.py:584`),两个名字都要提供。
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
from app.sdk.platforms.kw.util import format_singer, obj_str2json
LIST_LIMIT = 36
SONG_LIMIT = 1000
# 本地开窗最多翻到第 50 个服务端页,避免异常 total 导致长循环
MAX_SERVER_PAGES = 50
DETAIL_API = (
"http://search.kuwo.cn/r.s?pn={pn}&rn={rn}&stype=albuminfo&albumid={id}"
"&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0"
)
SEARCH_API = (
"http://search.kuwo.cn/r.s?all={keyword}&ft=album&itemset=web_2013&client=kt"
"&pcmp4=1&geo=c&vipver=1&pn={pn}&rn={rn}&rformat=json&encoding=utf8"
)
# `formats` 字段是 `|` 分隔的音质标记;这里只认对外暴露的四个档位
_FORMAT_QUALITY = (
("MP3128", "128k"),
("MP3H", "320k"),
("ALFLAC", "flac"),
("HIRFLAC", "flac24bit"),
)
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _filter_list_detail(raw_list: list[dict[str, Any]], album_name: str, album_id: Any) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
formats = str(item.get("formats") or "").split("|")
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for marker, quality in _FORMAT_QUALITY:
if marker not in formats:
continue
types.append({"type": quality, "size": None})
_types[quality] = {"size": None}
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": album_name,
"albumId": album_id,
"songmid": item.get("id"),
"source": "kw",
# 专辑接口不返回时长/封面,宁可为空也不猜
"interval": None,
"img": item.get("pic"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
async def get_album_list_detail(album_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
url = DETAIL_API.format(pn=page - 1, rn=SONG_LIMIT, id=album_id)
resp = await http_fetch(url)
if resp.status_code != 200:
return await get_album_list_detail(album_id, page, retry_num + 1)
body = obj_str2json(resp.raw.decode("utf-8", errors="replace"))
if not body.get("musiclist"):
return await get_album_list_detail(album_id, page, retry_num + 1)
album_name = decode_name(body.get("name"))
return {
"list": _filter_list_detail(body["musiclist"], album_name, body.get("albumid")),
"page": page,
"limit": SONG_LIMIT,
"total": _to_int(body.get("songnum")),
"source": "kw",
"info": {
"name": album_name,
"img": body.get("img") or body.get("hts_img"),
"desc": decode_name(body.get("info")),
"author": decode_name(body.get("artist")),
},
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 1000) -> dict[str, Any]:
"""统一形状的专辑详情。limit >= 1000 时服务端页大小够用,直通单页接口。"""
album_key = str(album_id or "").strip()
if not album_key:
raise RuntimeError("酷我专辑 id 为空")
page_no = max(1, _to_int(page) or 1)
page_size = max(1, _to_int(limit) or SONG_LIMIT)
if page_size >= SONG_LIMIT:
return await get_album_list_detail(album_key, page_no)
start = (page_no - 1) * page_size
server_page = start // SONG_LIMIT + 1
skip = start % SONG_LIMIT
songs: list[dict[str, Any]] = []
info: dict[str, Any] | None = None
total = 0
while len(songs) < page_size and server_page <= MAX_SERVER_PAGES:
res = await get_album_list_detail(album_key, server_page)
if not res:
break
if info is None:
info = res.get("info")
total = _to_int(res.get("total")) or total
raw_list = res.get("list") or []
if not raw_list:
break
songs.extend(raw_list[skip : skip + (page_size - len(songs))])
skip = 0
if len(raw_list) < SONG_LIMIT:
break
if total > 0 and len(songs) >= total:
break
server_page += 1
return {
"source": "kw",
"info": info or {"name": album_key, "img": "", "desc": "", "author": ""},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total or len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""聚合全部分页曲目并按 songmid 去重。"""
album_key = str(album_id or "").strip()
if not album_key:
raise RuntimeError("酷我专辑 id 为空")
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for page in range(1, MAX_SERVER_PAGES + 1):
res = await get_album_list_detail(album_key, page)
raw_list = res.get("list") or []
for song in raw_list:
key = str(song.get("songmid") or "")
if key and key in seen:
continue
if key:
seen.add(key)
songs.append(song)
total = _to_int(res.get("total"))
if len(raw_list) < SONG_LIMIT:
break
if total > 0 and len(songs) >= total:
break
return songs
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0}
page_no = max(1, _to_int(page) or 1)
page_size = max(1, _to_int(limit) or 10)
resp = await http_fetch(SEARCH_API.format(keyword=quote(word, safe=""), pn=page_no - 1, rn=page_size))
if resp.status_code != 200:
raise RuntimeError("酷我专辑搜索失败")
text = resp.raw.decode("utf-8", errors="replace")
try:
data = obj_str2json(text)
except Exception as err:
raise RuntimeError("酷我专辑搜索失败") from err
if not data.get("albumlist"):
raise RuntimeError("酷我专辑搜索失败")
result: list[dict[str, Any]] = []
for item in data["albumlist"]:
album_key = _first_non_empty(item.get("albumid"), item.get("id"))
if not album_key:
continue
result.append(
{
"id": album_key,
"name": decode_name(_first_non_empty(item.get("name"))),
"cover": _first_non_empty(item.get("hts_img"), item.get("img")),
"trackCount": _to_int(item.get("musiccnt")),
"playCount": _to_int(item.get("PLAYCNT") or item.get("playcnt")),
"creator": decode_name(_first_non_empty(item.get("aartist"), item.get("artist"))),
"description": decode_name(_first_non_empty(item.get("info"))),
"link": f"http://www.kuwo.cn/album_detail/{album_key}",
"source": "kw",
"type": "album",
"company": decode_name(_first_non_empty(item.get("company"))),
"publishTime": _first_non_empty(item.get("pub")),
}
)
return {
"source": "kw",
"list": result,
"page": page_no,
"limit": page_size,
"total": _to_int(data.get("TOTAL") or data.get("total")) or len(result),
}
async def parse_album_link(link: str) -> dict[str, Any]:
raw = str(link or "")
for pattern in (r"album_detail/(\d+)", r"album/(\d+)", r"albumid=(\d+)"):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1), 1)
raise RuntimeError("无效的酷我专辑链接")
__all__ = [
"get_album_detail",
"get_album_list_detail",
"get_album_songs",
"parse_album_link",
"search_album",
]
+89
View File
@@ -0,0 +1,89 @@
"""酷我评论(对应 sdk/kw/comment.ts)。
`ncomment.kuwo.cn/com.s` 只认安卓 Dalvik UA;`type=get_comment` 取最新评论、
`type=get_rec_comment` 取热评,两者响应字段名不同(comments/comments_counts
vs hot_comments/hot_comments_counts)。
"""
from __future__ import annotations
import math
from typing import Any
from urllib.parse import unquote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2
COMMENT_API = (
"http://ncomment.kuwo.cn/com.s?f=web&type={type}&aapiver=1&prod=kwplayer_ar_10.5.2.0"
"&digest=15&sid={songmid}&start={start}&msgflag=1&count={count}&newver=3&uid=0"
)
HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"}
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _reply_comment(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": item.get("id"),
"text": item.get("msg"),
"time": item.get("time"),
"timeStr": date_format2(_to_int(item.get("time")) * 1000),
"userName": item.get("u_name"),
"avatar": item.get("u_pic"),
"userId": item.get("u_id"),
"likedCount": item.get("like_num"),
"images": [item["mpic"]] if item.get("mpic") else [],
}
def _filter_comment(raw_list: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
if not raw_list:
return []
result: list[dict[str, Any]] = []
for item in raw_list:
data = _reply_comment(item)
data["images"] = [unquote(str(item["mpic"]))] if item.get("mpic") else []
# 顶层评论多一个楼中楼数组;原实现对父级 mpic 做 decodeURIComponent,子级不做
data["reply"] = [_reply_comment(child) for child in item.get("child_comments") or []]
result.append(data)
return result
async def _get_comment(songmid: str, page: int, limit: int, *, hot: bool) -> dict[str, Any]:
comment_type = "get_rec_comment" if hot else "get_comment"
url = COMMENT_API.format(type=comment_type, songmid=songmid, start=limit * (page - 1), count=limit)
resp = await http_fetch(url, headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or str(body.get("code")) != "200":
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
if hot:
total = _to_int(body.get("hot_comments_counts"))
comments = _filter_comment(body.get("hot_comments"))
else:
total = _to_int(body.get("comments_counts"))
comments = _filter_comment(body.get("comments"))
return {
"source": "kw",
"comments": comments,
"total": total,
"page": page,
"limit": limit,
# 原实现用 `Math.ceil(total/limit) || 1` 兜住 0 条
"maxPage": math.ceil(total / limit) if limit and total else 1,
}
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=False)
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]:
return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=True)
+28
View File
@@ -0,0 +1,28 @@
"""酷我热搜(对应 sdk/kw/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
HOT_API = (
"http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2"
"&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1&notrace=0&uid=0"
"&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1"
)
# 该接口只认移动端 Dalvik UA,桌面 UA 会返回非 JSON
HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"}
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
return [str(item.get("key") or "") for item in raw_list]
async def get_list() -> dict[str, Any]:
resp = await http_fetch(HOT_API, headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != "ok":
raise RuntimeError("获取热搜词失败")
return {"source": "kw", "list": _filter_list(body.get("tagvalue") or [])}
+155
View File
@@ -0,0 +1,155 @@
"""酷我榜单(对应 sdk/kw/leaderboard.ts)。
`get_boards` 直接返回硬编码榜单表(不发请求),id 形如 `kw__93`(路由层会剥掉前缀
再把裸 bangid 传回 `get_list`)。详情走 `wbd.kuwo.cn/api/bd/bang/bang_info`:
请求体整体 AES-128-ECB 加密 + 签名,响应同样是加密体,`util.wbd_*` 负责双向转换。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kw.util import format_singer, wbd_build_param, wbd_decode_data
LIST_DETAIL_LIMIT = 100
BOARD_LIST: list[dict[str, str]] = [
{"id": "kw__93", "name": "飙升榜", "bangid": "93"},
{"id": "kw__17", "name": "新歌榜", "bangid": "17"},
{"id": "kw__16", "name": "热歌榜", "bangid": "16"},
{"id": "kw__158", "name": "抖音热歌榜", "bangid": "158"},
{"id": "kw__292", "name": "铃声榜", "bangid": "292"},
{"id": "kw__284", "name": "热评榜", "bangid": "284"},
{"id": "kw__290", "name": "ACG新歌榜", "bangid": "290"},
{"id": "kw__286", "name": "台湾KKBOX榜", "bangid": "286"},
{"id": "kw__279", "name": "冬日暖心榜", "bangid": "279"},
{"id": "kw__281", "name": "巴士随身听榜", "bangid": "281"},
{"id": "kw__255", "name": "KTV点唱榜", "bangid": "255"},
{"id": "kw__280", "name": "家务进行曲榜", "bangid": "280"},
{"id": "kw__282", "name": "熬夜修仙榜", "bangid": "282"},
{"id": "kw__283", "name": "枕边轻音乐榜", "bangid": "283"},
{"id": "kw__278", "name": "古风音乐榜", "bangid": "278"},
{"id": "kw__264", "name": "Vlog音乐榜", "bangid": "264"},
{"id": "kw__242", "name": "电音榜", "bangid": "242"},
{"id": "kw__187", "name": "流行趋势榜", "bangid": "187"},
{"id": "kw__204", "name": "现场音乐榜", "bangid": "204"},
{"id": "kw__186", "name": "ACG神曲榜", "bangid": "186"},
{"id": "kw__185", "name": "最强翻唱榜", "bangid": "185"},
{"id": "kw__26", "name": "经典怀旧榜", "bangid": "26"},
{"id": "kw__104", "name": "华语榜", "bangid": "104"},
{"id": "kw__182", "name": "粤语榜", "bangid": "182"},
{"id": "kw__22", "name": "欧美榜", "bangid": "22"},
{"id": "kw__184", "name": "韩语榜", "bangid": "184"},
{"id": "kw__183", "name": "日语榜", "bangid": "183"},
{"id": "kw__145", "name": "会员畅听榜", "bangid": "145"},
{"id": "kw__153", "name": "网红新歌榜", "bangid": "153"},
{"id": "kw__64", "name": "影视金曲榜", "bangid": "64"},
{"id": "kw__176", "name": "DJ嗨歌榜", "bangid": "176"},
{"id": "kw__106", "name": "真声音", "bangid": "106"},
{"id": "kw__12", "name": "Billboard榜", "bangid": "12"},
{"id": "kw__49", "name": "iTunes音乐榜", "bangid": "49"},
{"id": "kw__180", "name": "beatport电音榜", "bangid": "180"},
{"id": "kw__13", "name": "英国UK榜", "bangid": "13"},
{"id": "kw__164", "name": "百大DJ榜", "bangid": "164"},
{"id": "kw__246", "name": "YouTube音乐排行榜", "bangid": "246"},
{"id": "kw__265", "name": "韩国Genie榜", "bangid": "265"},
{"id": "kw__14", "name": "韩国M-net榜", "bangid": "14"},
{"id": "kw__8", "name": "香港电台榜", "bangid": "8"},
{"id": "kw__15", "name": "日本公信榜", "bangid": "15"},
{"id": "kw__151", "name": "腾讯音乐人原创榜", "bangid": "151"},
]
BANG_API = "https://wbd.kuwo.cn/api/bd/bang/bang_info"
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
# 榜单列表按音质等级升序排列(128k → 320k → flac → flac24bit)
_QUALITY_RANK = {"flac24bit": 4, "flac": 3, "320k": 2, "128k": 1}
_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, str]] = []
_types: dict[str, dict[str, str]] = {}
seen: set[str] = set()
for entry in str(item.get("n_minfo") or "").split(";"):
match = _MININFO_RE.search(entry)
if match is None:
continue
bitrate = match.group(2)
if bitrate in seen:
continue
seen.add(bitrate)
quality = _BITRATE_QUALITY.get(bitrate)
if quality is None:
continue
# 原实现 types 留原样、_types 转大写,两者不完全一致
types.append({"type": quality, "size": match.group(4)})
_types[quality] = {"size": match.group(4).upper()}
types.sort(key=lambda entry: _QUALITY_RANK[entry["type"]])
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": decode_name(item.get("album")),
"albumId": item.get("albumId"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": item.get("pic"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "kw"}
async def get_list(board_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
retry_num += 1
if retry_num > 3:
raise RuntimeError("try max num")
body = {
"uid": "",
"devId": "",
"sFrom": "kuwo_sdk",
"user_type": "AP",
"carSource": "kwplayercar_ar_6.0.1.0_apk_keluze.apk",
"id": board_id,
"pn": page - 1,
"rn": LIST_DETAIL_LIMIT,
}
resp = await http_fetch(f"{BANG_API}?{wbd_build_param(body)}")
# 原实现直接调 decodeData,解密失败即 reject、不重试,这里保持一致
raw_data = wbd_decode_data(resp.body) if isinstance(resp.body, str) else {}
data = raw_data.get("data") or {}
if resp.status_code != 200 or raw_data.get("code") != 200 or not data.get("musiclist"):
return await get_list(board_id, page, retry_num)
return {
"total": _to_int(data.get("total")),
"list": _filter_data(data["musiclist"]),
"limit": LIST_DETAIL_LIMIT,
"page": page,
"source": "kw",
}
+124
View File
@@ -0,0 +1,124 @@
"""酷我歌词(对应 sdk/kw/lyric.ts)。
`newlyric.kuwo.cn/newlyric.lrc?<params>`:params 是明文字符串与 `yeelion` 逐字节异或
后的 base64。响应体是 zlib 压缩 + 可选 yeelion 异或,由 `lyric_decode` 还原。
歌词分两种形态:普通 lrc(`[mm:ss.xx]文本`)与 lrcx/逐字(每行带 `<start,dur>` 标记)。
lrcx 走 `util.parse_yrc` 归一到 `yrc` 字段,`lyric`/`tlyric` 里的逐字标记则剥掉,
保持纯 lrc 可直接给播放器。
"""
from __future__ import annotations
import base64
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
from app.sdk.platforms.kw.lyric_decode import handle_kw_decode_lyric
from app.sdk.platforms.kw.util import parse_yrc
_BUF_KEY = b"yeelion"
LYRIC_API = "http://newlyric.kuwo.cn/newlyric.lrc"
_TIME_EXP = re.compile(r"^\[([\d:.]*)\]")
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
_EXIST_TIME_EXP = re.compile(r"\[\d{1,2}:.*\d{1,4}\]")
_LYRICX_TAG = re.compile(r"^<-?\d+,-?\d+>")
_WORD_TIME_ALL = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
def build_params(songmid: str, is_get_lyricx: bool) -> str:
"""明文参数与 `yeelion` 循环异或后 base64。
原实现用 `new Uint16Array` 中转再 `Buffer.from(output)`;Node 对 TypedArray
走 fromArrayLike,按元素截断成 8 位字节,等价于这里的逐字节异或。
"""
params = f"user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_{songmid}"
if is_get_lyricx:
params += "&lrcx=1"
raw = params.encode()
return base64.b64encode(bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(raw))).decode()
def _sort_lrc_arr(arr: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""按时间去重:重复时间视为翻译行,挤进 lrc_t。
全部行都没有逐字标记、且「翻译行」占比异常时,认为数据错乱并抛 failed。
"""
lrc_set: set[str] = set()
lrc: list[dict[str, str]] = []
lrc_t: list[dict[str, str]] = []
is_lyricx = False
for item in arr:
if item["time"] in lrc_set:
if len(lrc) < 2:
continue
t_item = lrc.pop()
t_item["time"] = lrc[-1]["time"]
lrc_t.append(t_item)
lrc.append(item)
else:
lrc.append(item)
lrc_set.add(item["time"])
if not is_lyricx and _LYRICX_TAG.match(item["text"]):
is_lyricx = True
if not is_lyricx and len(lrc_t) > len(lrc) * 0.3 and len(lrc) - len(lrc_t) > 6:
raise RuntimeError("failed")
return lrc, lrc_t
def _transform_lrc(tags: list[str], lrc_list: list[dict[str, str]]) -> str:
body = "".join(f"[{item['time']}]{item['text']}\n" for item in lrc_list)
return "\n".join(tags) + "\n" + body
def parse_lrc(lrc: str) -> dict[str, str]:
tags: list[str] = []
arr: list[dict[str, str]] = []
for raw_line in re.split(r"\r\n|\r|\n", lrc):
line = raw_line.strip()
match = _TIME_EXP.match(line)
if match:
text = _TIME_EXP.sub("", line, count=1).strip()
time = match.group(1)
# 只有两位小数的补一个 0,与三位/更长的保持同宽
if re.search(r"\.\d\d$", time):
time += "0"
arr.append({"time": time, "text": text})
elif _TAG_LINE.search(line):
tags.append(line)
lrc_list, lrc_t = _sort_lrc_arr(arr)
return {
"lyric": decode_name(_transform_lrc(tags, lrc_list)),
"tlyric": decode_name(_transform_lrc(tags, lrc_t)) if lrc_t else "",
}
async def get_lyric(song_info: dict[str, Any], is_get_lyricx: bool = True) -> dict[str, str]:
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(f"{LYRIC_API}?{build_params(songmid, is_get_lyricx)}")
if resp.status_code != 200:
raise RuntimeError(json.dumps(resp.body, ensure_ascii=False))
base64_data = await handle_kw_decode_lyric(base64.b64encode(resp.raw).decode(), is_get_lyricx)
try:
lrc_info = parse_lrc(base64.b64decode(base64_data).decode("utf-8", errors="replace"))
except Exception as err:
raise RuntimeError("Get lyric failed") from err
if lrc_info.get("tlyric"):
lrc_info["tlyric"] = _WORD_TIME_ALL.sub("", lrc_info["tlyric"])
# yrc 解析失败只代表这份歌词没有逐字数据,不影响普通歌词返回
try:
lrc_info["yrc"] = parse_yrc(lrc_info["lyric"])
except Exception: # noqa: BLE001
lrc_info["yrc"] = ""
lrc_info["lyric"] = _WORD_TIME_ALL.sub("", lrc_info["lyric"])
if not _EXIST_TIME_EXP.search(lrc_info["lyric"]):
raise RuntimeError("Get lyric failed")
return lrc_info
+38
View File
@@ -0,0 +1,38 @@
"""酷我歌词解码(对应 sdk/kw/lyricDecode.ts)。
响应体前缀是 `tp=content`,`\\r\\n\\r\\n` 之后为 zlib 压缩数据;非 lrcx(普通歌词)
直接按 gb18030 解码,lrcx(逐字歌词)先 base64 解出、再与 `yeelion` 逐字节异或,
最后按 gb18030 解码。算法来源为酷我桌面端歌词解码实现。
"""
from __future__ import annotations
import base64
import zlib
_BUF_KEY = b"yeelion"
def _xor_yeelion(data: bytes) -> bytes:
"""逐字节与 `yeelion` 循环异或(原实现用双层 while,等价于 i % len(key))。"""
return bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(data))
async def decode_kw_lyric(buf: bytes, is_get_lyricx: bool) -> str:
"""解出明文歌词。非酷我歌词格式返回空串(与原实现一致)。"""
if buf[:10].decode("utf-8", errors="replace") != "tp=content":
return ""
# 找不到分隔时原实现 indexOf 返回 -1,+4 后从第 3 字节开始切片,这里保持一致
lrc_data = zlib.decompress(buf[buf.find(b"\r\n\r\n") + 4 :])
if not is_get_lyricx:
return lrc_data.decode("gb18030", errors="replace")
raw = base64.b64decode(lrc_data.decode("utf-8", errors="replace"))
return _xor_yeelion(raw).decode("gb18030", errors="replace")
async def handle_kw_decode_lyric(lrc_base64: str, is_get_lyricx: bool) -> str:
"""解码入口:入参与返回值都是 base64(对应原 handleKwDecodeLyric)。"""
lrc = await decode_kw_lyric(base64.b64decode(lrc_base64), is_get_lyricx)
return base64.b64encode(lrc.encode()).decode()
+116
View File
@@ -0,0 +1,116 @@
"""酷我搜索(对应 sdk/kw/musicSearch.ts)。
搜索接口 `search.kuwo.cn/r.s` 返回的是带单引号的 JS 对象字面量,必须走
`util.obj_str2json` 而不能直接 json.loads。音质清单在 `N_MINFO` 字段里,
`;` 分隔多档、每档形如 `level:ff,bitrate:2000,format:flac,size:32.43Mb`;
`mflac`/`mgg`/`mmp4`/`zp` 等加密码流的 bitrate 是 20000+ 的随机值,不在
4000/2000/320/128 四档之内,天然被过滤掉。
"""
from __future__ import annotations
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kw.util import format_singer, obj_str2json
DEFAULT_LIMIT = 30
SEARCH_API = (
"http://search.kuwo.cn/r.s?client=kt&all={keyword}&pn={pn}&rn={rn}"
"&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1"
"&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1"
)
# bitrate 精确值 → 对外音质名。只认这四档,加密档位一律丢弃。
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
_MINFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""`N_MINFO` 缺失时返回 None(原文用 console.log 提示后触发重试)。"""
result: list[dict[str, Any]] = []
for info in raw_list:
n_minfo = info.get("N_MINFO")
if not n_minfo:
return None
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for entry in str(n_minfo).split(";"):
match = _MINFO_RE.search(entry)
if match is None:
continue
quality = _BITRATE_QUALITY.get(match.group(2))
if quality is None:
continue
size = match.group(4)
types.append({"type": quality, "size": size})
_types[quality] = {"size": size.upper()}
# 原文按 N_MINFO 的书写顺序收集后整体反转(加密档在前,明文档在后)
types.reverse()
interval = _to_int(info.get("DURATION"))
result.append(
{
"name": decode_name(info.get("SONGNAME")),
"singer": format_singer(decode_name(info.get("ARTIST"))),
"source": "kw",
"songmid": str(info.get("MUSICRID") or "").replace("MUSIC_", ""),
"albumId": decode_name(info.get("ALBUMID") or ""),
"interval": format_play_time(interval),
"albumName": decode_name(info.get("ALBUM")) if info.get("ALBUM") else "",
"lrc": None,
"img": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _to_int(value: Any) -> int:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return 0
async def search(keyword: str, page: int = 1, limit: int | None = None, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=page_size)
resp = await http_fetch(url)
# 上游偶发返回非 JSON(风控页);原文靠 `!result` 兜住并重试
result = resp.body
if isinstance(result, str):
# rformat=json 正常回合法 JSON,但风控页/旧版接口会回单引号伪 JSON
try:
result = obj_str2json(result)
except ValueError:
return await search(keyword, page, limit, retry_num + 1)
if not isinstance(result, dict):
return await search(keyword, page, limit, retry_num + 1)
if result.get("TOTAL") != "0" and result.get("SHOW") == "0":
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(result.get("abslist") or [])
if songs is None:
return await search(keyword, page, limit, retry_num + 1)
total = _to_int(result.get("TOTAL"))
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"total": total,
"limit": page_size,
"source": "kw",
}
+117
View File
@@ -0,0 +1,117 @@
"""酷我链接解析(对应 sdk/kw/parse.ts)。
歌曲:`www.kuwo.cn/play_detail/<rid>`、`m.kuwo.cn/h5app/song/<rid>`、
`musicId=<rid>` 或裸 rid(4 位以上数字)。元数据取
`m.kuwo.cn/newh5/singles/songinfoandlrc`,风控时会回 status 301「音乐查询失败」,
按 600ms×(n+1) 退避重试最多 5 次(约 9 秒)。
直链是尽力的:版权/风控导致取流失败时仍返回元数据(与上游 fetchAudioURL 降级一致)。
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.kw import album, playlist
from app.sdk.platforms.kw.util import format_singer
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
SONG_PATTERNS = (
re.compile(r"play_detail/(\d+)"),
re.compile(r"h5app/song/(\d+)"),
re.compile(r"musicId=(\d+)"),
re.compile(r"^\d{4,}$"),
)
META_API = "http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId={rid}&httpsStatus=1"
MAX_META_RETRY = 5
def extract_song_id(link: Any) -> str:
raw = str(link if link is not None else "").strip()
for pattern in SONG_PATTERNS:
match = pattern.search(raw)
if match:
return match.group(1) if match.groups() else raw
return ""
def _to_int(value: Any) -> int:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return 0
async def fetch_song_meta(rid: str, retry_num: int = 0) -> dict[str, Any]:
resp = await http_fetch(
META_API.format(rid=rid),
method="get",
headers={"User-Agent": _UA, "Cookie": "wd=1;vip=0"},
timeout=15_000,
)
data: Any = resp.body
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
data = None
songinfo = ((data or {}).get("data") or {}).get("songinfo")
if not songinfo:
if retry_num < MAX_META_RETRY:
await asyncio.sleep(0.6 * (retry_num + 1))
return await fetch_song_meta(rid, retry_num + 1)
msg = (data or {}).get("msg") if isinstance(data, dict) else None
raise RuntimeError(msg or "酷我歌曲不存在或已下架")
return songinfo
async def parse_song(link: str) -> dict[str, Any]:
rid = extract_song_id(link)
if not rid:
raise RuntimeError("无效的酷我歌曲链接")
meta = await fetch_song_meta(rid)
duration = _to_int(meta.get("duration"))
album_id = meta.get("albumId")
song: dict[str, Any] = {
"songmid": rid,
"name": meta.get("songName") or f"Kuwo_Song_{rid}",
"singer": format_singer(str(meta.get("artist") or "").strip()),
"albumName": str(meta.get("album") or "").strip(),
"albumId": "" if album_id is None else str(album_id),
"interval": format_play_time(duration) if duration > 0 else "--/--",
"img": meta.get("pic") or None,
"lrc": None,
"otherSource": None,
"types": [],
"_types": {},
"typeUrl": {},
"source": "kw",
"extra": {"rid": rid, "link": f"http://www.kuwo.cn/play_detail/{rid}"},
}
try:
from app.sdk.apis.kw import get_music_url
hit = await get_music_url({"songmid": rid}, "128k")
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 版权/风控取流失败时仍返回元数据
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)
+22
View File
@@ -0,0 +1,22 @@
"""酷我封面(对应 sdk/kw/pic.ts)。
`artistpicserver.kuwo.cn/pic.web` 返回的是一段纯文本(不是 JSON),内容就是图片 URL;
取不到时返回 `None`(原实现用 `/^http/` 判定,不匹配即 null)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
PIC_API = "http://artistpicserver.kuwo.cn/pic.web"
PIC_PARAMS = "corp=kuwo&type=rid_pic&pictype=500&size=500"
async def get_pic(song_info: dict[str, Any]) -> str | None:
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(f"{PIC_API}?{PIC_PARAMS}&rid={songmid}")
text = resp.body if isinstance(resp.body, str) else ""
text = text.strip()
return text if text.startswith("http") else None
+255
View File
@@ -0,0 +1,255 @@
"""酷我歌单(对应 sdk/kw/playlist.ts)。
在 `song_list` 之上做统一形状:
* `get_playlist_detail(id, page, limit)` —— 服务端 1000 条/页,本地按 page/limit 开窗
* `get_playlist_songs(id)` —— 聚合全部页曲目
* `search_playlist` / `get_categories` / `get_category_playlists` / `get_recommended`
推荐(getRcmPlayList)服务端 limit 固定 36,显式更小的 limit 在本地截断。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.kw import song_list
SERVER_PAGE_SIZE = song_list.SONG_LIMIT
MAX_SERVER_PAGES = 60
_ID_PATTERNS = (
re.compile(r"playlist_detail/(\d+)"),
re.compile(r"/playlist/(\d+)"),
re.compile(r"playlistId=(\d+)"),
)
_DIGEST_PREFIX_RE = re.compile(r"^digest-\w+__")
def _js_number_or_raw(value: Any, default: Any = 0) -> Any:
"""复现 JS 的 `Number(x) → isFinite ? 数字 : 原值` 语义。
`format_play_count` 返回的是 `"1.2万"` 这类字符串,`Number("1.2万")` 是 NaN,
原实现于是把原字符串原样传下去;能解析成数字的才转成数字。
"""
if value is None or value == "":
return default
try:
num = float(value)
except (TypeError, ValueError):
return value
if not math.isfinite(num):
return value
return int(num) if num.is_integer() else num
def _to_number(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _to_int(value: Any) -> int:
return int(_to_number(value))
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def normalize_playlist_id(playlist_id: Any) -> str:
"""歌单链接 → 裸 id;digest 前缀形态原样保留(交给 song_list 分流)。"""
raw = "" if playlist_id is None else str(playlist_id).strip()
if not raw:
return ""
for pattern in _ID_PATTERNS:
match = pattern.search(raw)
if match:
return match.group(1)
return raw
def _to_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单摘要:在原始条目上补齐契约要求的 id/name/cover/trackCount/… 字段。"""
playlist_id = str(item.get("id") or "").strip()
plain_id = _DIGEST_PREFIX_RE.sub("", playlist_id)
return {
**item,
"id": playlist_id,
"name": str(item.get("name") or "").strip(),
"cover": _first_non_empty(item.get("img"), item.get("cover")),
# 原实现用 `??` 而非 `||`,total 为 0 时不会退化到 trackCount
"trackCount": _to_int(item["total"] if item.get("total") is not None else item.get("trackCount")) or 0,
"playCount": _js_number_or_raw(item.get("play_count")),
"creator": _first_non_empty(item.get("author"), item.get("creator"), item.get("uname")),
"description": _first_non_empty(item.get("desc"), item.get("description")),
"link": f"http://www.kuwo.cn/playlist_detail/{plain_id}" if plain_id else "",
"source": "kw",
}
def _windowed(result: dict[str, Any], page: int, limit: int) -> dict[str, Any]:
"""推荐/分类接口的 limit 由服务端固定,显式更小时本地截断。"""
requested = _to_int(limit)
server_limit = _to_int(result.get("limit"))
songs = [item for item in (_to_summary(raw) for raw in result.get("list") or []) if item["id"]]
if requested > 0 and len(songs) > requested:
songs = songs[:requested]
effective = min(requested, server_limit) if requested > 0 and server_limit > 0 else server_limit
return {
"source": "kw",
"list": songs,
"page": page,
"limit": effective or len(songs),
"total": _to_int(result.get("total")) or len(songs),
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0}
res = await song_list.search(word, page, limit)
songs = [item for item in (_to_summary(raw) for raw in res.get("list") or []) if item["id"]]
return {
"source": "kw",
"list": songs,
"page": page,
"limit": limit,
"total": _to_int(res.get("total")) or len(songs),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
raw_id = normalize_playlist_id(playlist_id)
if not raw_id:
raise RuntimeError("酷我歌单 id 为空")
page_no = max(1, _to_int(page) or 1)
page_size = max(1, _to_int(limit) or 50)
start = (page_no - 1) * page_size
server_page = start // SERVER_PAGE_SIZE + 1
skip = start % SERVER_PAGE_SIZE
songs: list[dict[str, Any]] = []
info: dict[str, Any] | None = None
total = 0
while len(songs) < page_size and server_page <= MAX_SERVER_PAGES:
res = await song_list.get_list_detail(raw_id, server_page)
if not res:
break
if info is None:
info = res.get("info")
total = _to_int(res.get("total")) or total
raw_list = res.get("list") or []
if not raw_list:
break
songs.extend(raw_list[skip : skip + (page_size - len(songs))])
skip = 0
if len(raw_list) < SERVER_PAGE_SIZE:
break
if total > 0 and server_page * SERVER_PAGE_SIZE >= total:
break
server_page += 1
info = info or {}
return {
"source": "kw",
"info": {
"name": _first_non_empty(info.get("name"), raw_id),
"img": _first_non_empty(info.get("img")),
"desc": _first_non_empty(info.get("desc")),
"author": _first_non_empty(info.get("author")),
"play_count": info.get("play_count", ""),
},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total or len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""聚合全部页曲目并按 songmid 去重。"""
raw_id = normalize_playlist_id(playlist_id)
if not raw_id:
raise RuntimeError("酷我歌单 id 为空")
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for server_page in range(1, MAX_SERVER_PAGES + 1):
res = await song_list.get_list_detail(raw_id, server_page)
raw_list = res.get("list") or []
for song in raw_list:
key = str(song.get("songmid") or "")
if key and key in seen:
continue
if key:
seen.add(key)
songs.append(song)
total = _to_int(res.get("total"))
if len(raw_list) < SERVER_PAGE_SIZE:
break
if total > 0 and len(songs) >= total:
break
return songs
async def get_categories() -> dict[str, Any]:
tags_data = await song_list.get_tags()
hot_ids = {str(item.get("id") or "") for item in tags_data.get("hotTag") or []}
result: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
for group in tags_data.get("tags") or []:
for item in group.get("list") or []:
tag_id = str(item.get("id") or "").strip()
name = str(item.get("name") or "").strip()
if not tag_id or not name:
continue
result.append(
{
"id": tag_id,
"name": name,
"group": str(group.get("name") or "").strip() or "其他",
"hot": tag_id in hot_ids,
}
)
return {"source": "kw", "list": result}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
tag_id = str(category_id or "").strip()
page_no = max(1, _to_int(page) or 1)
res = await song_list.get_list("hot", tag_id or None, page_no)
return _windowed(res, page_no, limit)
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
page_no = max(1, _to_int(page) or 1)
res = await song_list.get_list("hot", None, page_no)
return _windowed(res, page_no, limit)
async def parse_playlist_link(link: str) -> dict[str, Any]:
raw = str(link or "")
for pattern in _ID_PATTERNS:
match = pattern.search(raw)
if match:
return await get_playlist_detail(match.group(1), 1)
raise RuntimeError("无效的酷我歌单链接")
__all__ = [
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"get_recommended",
"normalize_playlist_id",
"parse_playlist_link",
"search_playlist",
]
+517
View File
@@ -0,0 +1,517 @@
"""酷我歌单广场与歌单详情(对应 sdk/kw/songList.ts)。
四条互不相同的上游链路,按歌单 id 形态分流(`get_list_detail`):
* `digest-<n>__<id>` —— 广场列表返回的内部 id,digest 决定走哪个接口:
13 → 专辑(转交 album.get_album_list_detail)
5 → 旧歌单(先 qukudata 查 sourceid,再用 nplserver 取曲目)
其余 → nplserver 直取
* `/bodian/` 链接 —— BD 接口(bd-api.kuwo.cn),需要 reqId 与 h5 头
* 网页链接 —— 正则抽出裸 id 后按 nplserver 取
nplserver 固定 1000 条/页,因此详情类调用都在调用方(playlist.py)做本地开窗。
"""
from __future__ import annotations
import asyncio
import random
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_count, format_play_time
from app.sdk.platforms.kw.util import format_singer, obj_str2json
LIST_LIMIT = 36
SONG_LIMIT = 1000
SUCCESS_CODE = 200
SORT_LIST: list[dict[str, str]] = [
{"name": "最新", "id": "new"},
{"name": "最热", "id": "hot"},
]
# 路由层以 `song_list.sort_list[0]["id"]` 取默认排序,名字沿用原 TS 的 sortList
sort_list = SORT_LIST
TAGS_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist"
"&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0"
"&loginUid=0&loginSid=0&appUid=76039576"
)
HOT_TAG_API = "http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576"
RCM_LIST_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0"
"&appUid=76039576&&pn={page}&rn={rn}&order={sort}"
)
TAG_LIST_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0"
"&appUid=76039576&pn={page}&id={id}&rn={rn}"
)
QZ_LIST_API = "http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id={id}&prod=pc"
LIST_DETAIL_API = (
"http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}"
"&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1"
)
DIGEST5_INFO_API = "http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node={id}&pn=0&rn=1&fmt=json&src=mbox&level=2"
DIGEST5_MUSIC_API = (
"http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}"
"&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1"
)
BD_INFO_API = "https://bd-api.kuwo.cn/api/service/playlist/info/{id}?reqId={req_id}&source={source}"
BD_USER_API = "https://bd-api.kuwo.cn/api/ucenter/users/pub/{id}?reqId={req_id}"
BD_LIST_API = (
"https://bd-api.kuwo.cn/api/service/playlist/{id}/musicList?reqId={req_id}&source={source}&pn={page}&rn={rn}"
)
SEARCH_API = (
"http://search.kuwo.cn/r.s?all={keyword}&pn={pn}&rn={rn}&rformat=json&encoding=utf8"
"&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0"
)
BD_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
),
"plat": "h5",
}
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
_DETAIL_LINK_RE = re.compile(r"^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)")
_LINKISH_RE = re.compile(r"[?&:/]")
_DIGEST_RE = re.compile(r"^digest-")
_BD_RE = re.compile(r"/bodian/")
_DIGEST_SPLIT_RE = re.compile(r"__")
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _as_str(value: Any) -> str:
return "" if value is None else str(value)
def _get_list_url(sort_id: str, playlist_id: str | None, digest: str, page: int) -> str:
if not playlist_id:
return RCM_LIST_API.format(page=page, rn=LIST_LIMIT, sort=sort_id)
if digest == "10000":
return TAG_LIST_API.format(page=page, id=playlist_id, rn=LIST_LIMIT)
if digest == "43":
return QZ_LIST_API.format(id=playlist_id)
# 原文此处无 return,httpFetch(undefined) 会直接抛错;这里显式抛同样的异常语义
raise RuntimeError(f"不支持的酷我歌单标签类型: {digest}")
def _filter_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, str]] = []
_types: dict[str, dict[str, str]] = {}
for entry in str(item.get("N_MINFO") or "").split(";"):
match = _MININFO_RE.search(entry)
if match is None:
continue
quality = _BITRATE_QUALITY.get(match.group(2))
if quality is None:
continue
types.append({"type": quality, "size": match.group(4)})
_types[quality] = {"size": match.group(4).upper()}
types.reverse()
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": decode_name(item.get("album")),
"albumId": item.get("albumid"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": None,
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _filter_list(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"play_count": format_play_count(_to_int(item.get("listencnt"))),
"id": f"digest-{item.get('digest')}__{item.get('id')}",
"author": item.get("uname"),
"name": item.get("name"),
"total": item.get("total"),
"img": item.get("img"),
"grade": _to_int(item.get("favorcnt")) / 10,
"desc": item.get("desc"),
"source": "kw",
}
for item in raw_list
]
def _filter_list2(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""qz_data 接口返回按 label 分组的二维结构,逐组展开成一维。
原实现用 `a && f(b)` 做兜底:条件为真才格式化,否则原样返回条件值。
play_count 用 `item.play_count` 作条件却格式化 `item.listencnt`,逐个照搬。
"""
result: list[dict[str, Any]] = []
for group in raw_data:
if not group.get("label"):
continue
for item in group.get("list") or []:
raw_play = item.get("play_count")
raw_favor = item.get("favorcnt")
result.append(
{
"play_count": format_play_count(_to_int(item.get("listencnt"))) if raw_play else (raw_play or 0),
"id": f"digest-{item.get('digest')}__{item.get('id')}",
"author": item.get("uname"),
"name": item.get("name"),
"total": item.get("total"),
"img": item.get("img"),
"grade": _to_int(raw_favor) / 10 if raw_favor else (raw_favor or 0),
"desc": item.get("desc"),
"source": "kw",
}
)
return result
def _detail_info(body: dict[str, Any]) -> dict[str, Any]:
return {
"name": body.get("title"),
"img": body.get("pic"),
"desc": body.get("info"),
"author": body.get("uname"),
"play_count": format_play_count(_to_int(body.get("playnum"))),
}
async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(TAGS_API)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(retry_num + 1)
return [
{
"name": group.get("name"),
"list": [
{
"parent_id": group.get("id"),
"parent_name": group.get("name"),
"id": f"{item.get('id')}-{item.get('digest')}",
"name": item.get("name"),
"source": "kw",
}
for item in group.get("data") or []
],
}
for group in body.get("data") or []
]
async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_TAG_API)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_hot_tag(retry_num + 1)
groups = body.get("data") or [{}]
return [
{"id": f"{item.get('id')}-{item.get('digest')}", "name": item.get("name"), "source": "kw"}
for item in (groups[0] or {}).get("data") or []
]
async def get_tags() -> dict[str, Any]:
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag())
return {"tags": tags, "hotTag": hot_tag, "source": "kw"}
async def get_list(sort_id: str, tag_id: str | None = None, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
playlist_id: str | None = None
digest = ""
if tag_id:
parts = tag_id.split("-")
playlist_id = parts[0]
digest = parts[1] if len(parts) > 1 else ""
url = _get_list_url(sort_id, playlist_id, digest, page)
resp = await http_fetch(url)
body = resp.body
if not playlist_id or digest == "10000":
if not isinstance(body, dict) or body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, retry_num + 1)
data = body.get("data") or {}
return {
"list": _filter_list(data.get("data") or []),
"total": data.get("total"),
"page": data.get("pn"),
"limit": data.get("rn"),
"source": "kw",
}
if not isinstance(body, list) or not body:
return await get_list(sort_id, tag_id, page, retry_num + 1)
return {"list": _filter_list2(body), "total": SONG_LIMIT, "page": page, "limit": SONG_LIMIT, "source": "kw"}
async def get_list_detail_digest8(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(LIST_DETAIL_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("result") != "ok":
return await get_list_detail(playlist_id, page, retry_num + 1)
return {
"list": _filter_list_detail(body.get("musiclist") or []),
"page": page,
"limit": body.get("rn"),
"total": body.get("total"),
"source": "kw",
"info": _detail_info(body),
}
async def get_list_detail_digest5_info(playlist_id: str, retry_num: int = 0) -> str | None:
"""digest=5 的歌单 id 不能直接给 nplserver,要先查出真正的 sourceid。"""
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(DIGEST5_INFO_API.format(id=playlist_id))
body = resp.body if isinstance(resp.body, dict) else {}
children = body.get("child")
if resp.status_code != 200 or not children:
return await get_list_detail_digest5_info(playlist_id, retry_num + 1)
return str(children[0].get("sourceid")) if children else None
async def get_list_detail_digest5_music(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(DIGEST5_MUSIC_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("result") != "ok":
return await get_list_detail(playlist_id, page, retry_num + 1)
return {
"list": _filter_list_detail(body.get("musiclist") or []),
"page": page,
"limit": body.get("rn"),
"total": body.get("total"),
"source": "kw",
"info": _detail_info(body),
}
async def get_list_detail_digest5(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
detail_id = await get_list_detail_digest5_info(playlist_id, retry_num)
if detail_id is None:
raise RuntimeError("failed")
return await get_list_detail_digest5_music(detail_id, page, retry_num)
def _filter_bd_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for audio in item.get("audios") or []:
size = _as_str(audio.get("size")).upper() if audio.get("size") is not None else None
quality = _BITRATE_QUALITY.get(_as_str(audio.get("bitrate")))
if quality is None:
continue
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
types.reverse()
result.append(
{
"singer": "、".join(_as_str(s.get("name")) for s in item.get("artists") or []),
"name": item.get("name"),
"albumName": item.get("album"),
"albumId": item.get("albumId"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": item.get("albumPic"),
"releaseDate": item.get("releaseDate"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _get_req_id() -> str:
"""BD 接口的 reqId:八段随机 16 进制。
原实现 `(65536 * (1 + Math.random()) | 0).toString(16).substring(1)`:
值落在 [0x10000, 0x1ffff],固定 5 位 16 进制,去掉首位后取 4 位。
"""
def segment() -> str:
return f"{int(65536 * (1 + random.random())):05x}"[1:]
return "".join(segment() for _ in range(8))
async def _bd_playlist_info(playlist_id: str, source: str) -> dict[str, Any] | None:
resp = await http_fetch(BD_INFO_API.format(id=playlist_id, req_id=_get_req_id(), source=source), headers=BD_HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
return None
data = body.get("data") or {}
return {
"name": data.get("name"),
"img": data.get("pic"),
"desc": data.get("description"),
"author": data.get("creatorName"),
"play_count": data.get("playNum"),
}
async def _bd_user_pub_info(user_id: str) -> dict[str, Any] | None:
resp = await http_fetch(BD_USER_API.format(id=user_id, req_id=_get_req_id()), headers=BD_HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
return None
info = (body.get("data") or {}).get("userInfo") or {}
nickname = _as_str(info.get("nickname"))
return {
"name": nickname + "喜欢的音乐",
"img": info.get("headImg"),
"desc": "",
"author": nickname,
"play_count": "",
}
async def _bd_playlist_songs(playlist_id: str, source: str, page: int, retry_num: int = 0) -> dict[str, Any]:
try:
resp = await http_fetch(
BD_LIST_API.format(id=playlist_id, req_id=_get_req_id(), source=source, page=page, rn=SONG_LIMIT),
headers=BD_HEADERS,
)
except Exception as err:
if retry_num > 2:
raise RuntimeError("try max num") from err
return await _bd_playlist_songs(playlist_id, source, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
raise RuntimeError("failed")
data = body.get("data") or {}
return {
"list": _filter_bd_list_detail(data.get("list") or []),
"page": page,
"limit": data.get("pageSize"),
"total": data.get("total"),
"source": "kw",
}
def _match_group(link: str, pattern: str) -> str | None:
found = re.search(pattern, link)
return found.group(1) if found else None
async def _get_list_detail_by_bd(raw_id: str, page: int) -> dict[str, Any]:
"""bodian 分享链接:uid/playlistId/source 都在 query 里,缺 source 时按原实现拼字面量。"""
uid = _match_group(raw_id, r"uid=(\d+)")
playlist_id = _match_group(raw_id, r"playlistId=(\d+)")
source = _match_group(raw_id, r"source=(\d+)") or "undefined"
if not playlist_id:
raise RuntimeError("failed")
tasks: list[Any] = [_bd_playlist_songs(playlist_id, source, page)]
if source == "4":
tasks.append(_bd_playlist_info(playlist_id, source))
elif source == "5":
tasks.append(_bd_user_pub_info(uid or playlist_id))
results = await asyncio.gather(*tasks)
list_data = results[0]
info = results[1] if len(results) > 1 else None
list_data["info"] = info or {"name": "", "img": "", "desc": "", "author": "", "play_count": ""}
return list_data
async def get_list_detail(playlist_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if _BD_RE.search(playlist_id):
return await _get_list_detail_by_bd(playlist_id, page)
if _LINKISH_RE.search(playlist_id):
playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id)
elif _DIGEST_RE.search(playlist_id):
parts = _DIGEST_SPLIT_RE.split(playlist_id)
digest = parts[0].replace("digest-", "")
playlist_id = parts[1] if len(parts) > 1 else ""
if digest == "13":
from app.sdk.platforms.kw import album
return await album.get_album_list_detail(playlist_id, page, retry_num)
# 原文 switch 里只有 '8' 会 break 落到 digest8,其余(含 '5' 与未知 digest)全走 digest5
if digest == "8":
return await get_list_detail_digest8(playlist_id, page, retry_num)
return await get_list_detail_digest5(playlist_id, page, retry_num)
return await get_list_detail_digest8(playlist_id, page, retry_num)
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
resp = await http_fetch(SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=limit))
body = obj_str2json(resp.raw.decode("utf-8", errors="replace"))
return {
"list": [
{
"play_count": format_play_count(_to_int(item.get("playcnt"))),
"id": _as_str(item.get("playlistid")),
"author": decode_name(item.get("nickname")),
"name": decode_name(item.get("name")),
"total": item.get("songnum"),
"img": item.get("pic"),
"desc": decode_name(item.get("intro")),
"source": "kw",
}
for item in body.get("abslist") or []
],
"limit": limit,
"total": _to_int(body.get("TOTAL")),
"source": "kw",
}
def get_detail_page_url(playlist_id: str) -> str:
if _LINKISH_RE.search(playlist_id):
playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id)
elif _DIGEST_RE.search(playlist_id):
parts = playlist_id.split("__")
playlist_id = parts[1] if len(parts) > 1 else ""
return f"http://www.kuwo.cn/playlist_detail/{playlist_id}"
__all__ = [
"SORT_LIST",
"get_detail_page_url",
"get_hot_tag",
"get_list",
"get_list_detail",
"get_tag",
"get_tags",
"search",
]
+192
View File
@@ -0,0 +1,192 @@
"""酷我平台工具(对应 sdk/kw/util.ts)。
四块内容:legacy 接口的 JS 对象字面量转 JSON、歌手名格式化、逐字歌词(lrcx)解析、
wbd 榜单接口的 AES-128-ECB 加解密与签名。
"""
from __future__ import annotations
import base64
import json
import math
import random
import re
import time
from typing import Any
from urllib.parse import quote, unquote
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import to_md5
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
MOBI_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 "
"(KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36"
)
HOME_URL = "https://www.kuwo.cn/"
# 单引号替换:原文是一条含变长后顾断言的正则,Python re 不支持变长 lookbehind,
# 因此拆成「引号前是 {,: 」与「引号后是 },: 」两步,覆盖范围与原文等价。
_QUOTE_START = re.compile(r"(?P<pre>[{:,]\s*)'")
_QUOTE_END = re.compile(r"'(?P<post>\s*[}:,])")
def obj_str2json(text: str) -> Any:
"""legacy r.s 返回的是 JS 对象字面量(单引号 + 未加引号的形态),先转成合法 JSON。
专辑详情实测确认仍是这一形态,不能直接 json.loads。
"""
fixed = _QUOTE_START.sub(r'\g<pre>"', text)
fixed = _QUOTE_END.sub(r'"\g<post>', fixed)
return json.loads(fixed)
def format_singer(raw: str) -> str:
"""酷我用 `&` 分隔合唱歌手,统一换成顿号。"""
return raw.replace("&", "、")
# ---------- 逐字歌词 ----------
_WORD_LINE = re.compile(r"^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*")
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
_WORD_TIME = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
_LINE_SPLIT = re.compile(r"\r\n|\r|\n")
def _js_number(value: float) -> str:
"""JS 的 Number → String:整数值不带小数点,其余取最短往返表示(与 repr 同)。"""
return str(int(value)) if float(value).is_integer() else repr(float(value))
class LrcTools:
"""lrcx 逐字歌词解析器。原实现靠 Object.create 做每轮隔离,这里用新实例。"""
def __init__(self) -> None:
self.offset = 1
self.offset2 = 1
self.is_ok = True
self.lines: list[str] = []
self.tags: list[str] = []
def get_word_info(self, first: str, second: str, prev: dict[str, Any] | None) -> dict[str, Any]:
offset = int(first)
offset2 = int(second)
start = abs((offset + offset2) / (self.offset * 2))
end = abs((offset - offset2) / (self.offset2 * 2)) + start
if prev is not None and start < prev["end"]:
# 上一句被本句起点截断,需要回头改写已拼进 words 的那一段
prev["end"] = start
if prev["start"] > prev["end"]:
prev["start"] = prev["end"]
prev["new"] = f"<{_js_number(prev['start'])},{_js_number(prev['end'] - prev['start'])}>"
return {"start": start, "end": end, "text": f"<{_js_number(start)},{_js_number(end - start)}>"}
def parse_line(self, line: str) -> None:
if len(line) < 6:
return
match = _WORD_LINE.match(line)
if match:
head = match.group(1)
words = match.group(2) or ""
spans = [item.group(0) for item in _WORD_TIME.finditer(words)]
if not spans:
return
prev: dict[str, Any] | None = None
for span in spans:
found = _WORD_TIME.search(span)
if found is None:
continue
info = self.get_word_info(found.group(1), found.group(2), prev)
words = words.replace(span, info["text"], 1)
if prev is not None and prev.get("new"):
words = words.replace(prev["text"], prev["new"], 1)
prev = info
self.lines.append(head + words)
return
tag = _TAG_LINE.search(line)
if tag is None:
return
if tag.group(1) != "kuwo":
self.tags.append(line)
return
content = tag.group(2)
if "][" in content:
content = content[: content.index("][")]
try:
value = int(content.strip(), 8)
except ValueError:
self.is_ok = False
return
self.offset = int(value / 10)
self.offset2 = value % 10
# 原文对 NaN/0 一律判失败,等价于这里两个偏移都非零
if self.offset == 0 or self.offset2 == 0:
self.is_ok = False
def parse_yrc(lrc: str) -> str:
"""把歌词里的 lrcx 时间标记规范化;失败抛 RuntimeError(原文文案 failed)。"""
tools = LrcTools()
for line in _LINE_SPLIT.split(lrc):
if not tools.is_ok:
raise RuntimeError("failed")
tools.parse_line(line)
if not tools.lines:
return ""
merged = "\n".join(tools.lines)
if tools.tags:
merged = "\n".join(tools.tags) + "\n" + merged
return merged
# ---------- wbd 榜单接口加解密 ----------
WBD_APP_ID = "y67sprxhhpws"
_WBD_KEY = bytes([112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228])
def _aes_ecb(data: bytes, *, encrypt: bool) -> bytes:
cipher = Cipher(algorithms.AES(_WBD_KEY), modes.ECB())
if encrypt:
pad = 16 - len(data) % 16
padded = data + bytes([pad]) * pad
context = cipher.encryptor()
return context.update(padded) + context.finalize()
context = cipher.decryptor()
out = context.update(data) + context.finalize()
pad = out[-1] if out else 0
return out[:-pad] if 0 < pad <= 16 else out
def wbd_decode_data(payload: str) -> Any:
"""解码 wbd 响应:URL 编码的 base64 → AES-128-ECB 解密 → JSON。"""
raw = base64.b64decode(unquote(payload))
return json.loads(_aes_ecb(raw, encrypt=False).decode())
def wbd_build_param(json_data: Any) -> str:
"""构造 wbd 请求 query:加密体 + 毫秒时间戳 + appId + MD5 大写签名。"""
data = json.dumps(json_data, ensure_ascii=False, separators=(",", ":")).encode()
time_ms = int(time.time() * 1000)
encoded = base64.b64encode(_aes_ecb(data, encrypt=True)).decode()
sign = to_md5(f"{WBD_APP_ID}{encoded}{time_ms}").upper()
return f"data={quote(encoded, safe='')}&time={time_ms}&appId={WBD_APP_ID}&sign={sign}"
async def fetch_home_set_cookie() -> list[str]:
"""取酷我首页下发的全部 Set-Cookie。
用 http_fetch 的 set_cookies(原始多值头),而不是折叠后的 headers dict——
Secret 签名必须认出动态下发的 Hm_Iuvt_* cookie(名字与值均随机),
同名头被折叠会恰好丢掉目标 cookie。
"""
resp = await http_fetch(HOME_URL, headers={"User-Agent": UA}, timeout=15_000)
return list(resp.set_cookies)
def random_digits() -> int:
"""JS 的 Math.round(1e9 * Math.random()) —— round 是四舍五入,不是 Python 的银行家舍入。"""
return math.floor(1e9 * random.random() + 0.5)
+80
View File
@@ -0,0 +1,80 @@
"""懒人听书(lrts.me)平台模块(对应 sdk/lr/index.ts)。
匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
章节 songmid 为复合 id ``<bookId>:<section>``;有声内容不参与聚合(noAggregate)。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.lr import menu, music_search
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.lr import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(_song_info: dict[str, Any]) -> None:
"""书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回 None 由调用方回退)。"""
return None
async def get_album_detail(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
result = await menu.get_book_menu(book_id, page, limit)
total = result["total"]
songs = []
for item in result["list"][:limit]:
if not isinstance(item, dict):
continue
section = item.get("section")
songs.append(
{
"songmid": f"{book_id}:{section}",
"name": item.get("name") or f"第{section}节",
"singer": "",
"albumId": str(book_id),
"albumName": "",
"interval": 0,
"payPlay": str(item.get("listenPrice")) != "0",
"img": None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"info": {"name": f"书籍 {book_id}", "total": total},
"list": songs,
"total": total,
"limit": limit,
"allPage": math.ceil(total / 30) or page,
"source": "lr",
}
class _LrAlbum:
get_album_detail = staticmethod(get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
book_id = str(song_info.get("songmid") or "").split(":")[0]
return f"https://m.lrts.me/book/{book_id}"
MODULE = Platform(
id="lr",
name="懒人听书",
music_search=music_search,
album=_LrAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+47
View File
@@ -0,0 +1,47 @@
"""懒人听书 App API(对应 sdk/lr/appApi.ts,dapis.mting.info)—— 搜索通道(游客 token 匿名可用)。
签名(移植 loyalflower/lrts_download download.go):
sc = md5(urlPath + "?" + <按 key 排序的 query(不含 sc) 拼接> + secret),secret = "iJ0DgxmdC83#I&j@iwg"
头:UA Android13/yyting/... / Referer yytingting.com / ClientVersion 6.3.4.0
端点:/yyting/search/searchBatch.action(q=2150)
注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.py)。
"""
from __future__ import annotations
import hashlib
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
DOMAIN = "http://dapis.mting.info"
SECRET = "iJ0DgxmdC83#I&j@iwg"
GUEST_TOKEN = "OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU"
GUEST_IMEI = "MDI6MDA6MDA6MDA6MDA6DDA="
APP_HEADERS = {
"User-Agent": "Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android",
"Referer": "yytingting.com",
"ClientVersion": "6.3.4.0",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def signed(path: str, params: dict[str, str]) -> str:
query: dict[str, str] = {"token": GUEST_TOKEN, "imei": GUEST_IMEI, "nwt": "1", **params}
qs = "&".join(f"{key}={query[key]}" for key in sorted(query))
query["sc"] = hashlib.md5(f"{path}?{qs}{SECRET}".encode()).hexdigest()
return f"{DOMAIN}{path}?" + "&".join(f"{key}={encode_uri_component(value)}" for key, value in query.items())
async def app_api_get(path: str, params: dict[str, str]) -> dict[str, Any]:
"""App API GET(游客态,仅搜索可用;章节/取流会 482 需登录)。"""
resp = await http_fetch(signed(path, params), headers=APP_HEADERS, timeout=15_000)
if resp.status_code != 200:
raise RuntimeError(f"懒人 App API HTTP {resp.status_code}")
return resp.body if isinstance(resp.body, dict) else {}
+33
View File
@@ -0,0 +1,33 @@
"""懒人听书章节列表(对应 sdk/lr/menu.ts,web 通道,匿名免签)。
接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)。
"""
from __future__ import annotations
from typing import Any
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"
)
MENU_URL = "https://m.lrts.me/ajax/getBookMenu"
async def get_book_menu(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
resp = await http_fetch(
f"{MENU_URL}?bookId={book_id}&pageNum={page}&pageSize={max(30, limit)}&sortType=0",
headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/"},
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError(f"获取章节失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
if body.get("status") == 1 or not body.get("list"):
raise RuntimeError(str(body.get("msg") or "获取章节失败"))
return {"total": int(body.get("sections") or 0), "list": as_list(body.get("list"))}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more