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