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
@@ -42,3 +42,10 @@ coverage/
|
||||
# 以下规则仅作保险,防止旧的发射产物被误生成后提交。
|
||||
src/**/*.js
|
||||
sdk/**/*.js
|
||||
|
||||
# Python(uv)
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,12 @@
|
||||
"""MusicServer —— 多平台音乐聚合 API(Python 版)。
|
||||
|
||||
包布局:
|
||||
app/config.py 环境变量与路径
|
||||
app/sdk/ 平台 SDK(HTTP 请求、工具、凭据、14 个平台适配器)
|
||||
app/http/ 服务端横切能力(错误、鉴权、限流、流签名、咪咕解密代理)
|
||||
app/routes/ 业务路由
|
||||
app/openapi/ OpenAPI 领域模型
|
||||
app/main.py FastAPI 应用入口
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -0,0 +1,65 @@
|
||||
"""环境变量与路径配置。
|
||||
|
||||
对应原 src/env.ts:读取项目根目录 .env,已存在的真实环境变量优先。
|
||||
必须在任何读取 os.environ 的模块导入之前调用 load_env()(main.py 第一行)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _parse_line(line: str) -> tuple[str, str] | None:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
return None
|
||||
if line.startswith("export "):
|
||||
line = line[7:].strip()
|
||||
key, sep, value = line.partition("=")
|
||||
if not sep:
|
||||
return None
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
||||
value = value[1:-1]
|
||||
return (key, value) if key else None
|
||||
|
||||
|
||||
def load_env(path: Path | None = None) -> None:
|
||||
"""加载 .env(不覆盖已存在的环境变量);缺失或解析失败时静默跳过。"""
|
||||
env_path = path or ROOT / ".env"
|
||||
if not env_path.exists():
|
||||
return
|
||||
try:
|
||||
text = env_path.read_text(encoding="utf-8")
|
||||
except OSError as err: # 与 process.loadEnvFile 的容错一致
|
||||
print(f"[env] .env 读取失败,已忽略: {err}")
|
||||
return
|
||||
for line in text.splitlines():
|
||||
parsed = _parse_line(line)
|
||||
if parsed:
|
||||
os.environ.setdefault(parsed[0], parsed[1])
|
||||
|
||||
|
||||
def data_dir() -> Path:
|
||||
"""凭据存储目录(DATA_DIR 可覆盖,测试用)。"""
|
||||
custom = os.environ.get("DATA_DIR")
|
||||
return Path(custom) if custom else ROOT / "data"
|
||||
|
||||
|
||||
def port() -> int:
|
||||
"""默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间。"""
|
||||
return int(os.environ.get("PORT") or 16688)
|
||||
|
||||
|
||||
def auth_key() -> str:
|
||||
return os.environ.get("AUTH_KEY") or ""
|
||||
|
||||
|
||||
def rate_limit() -> int:
|
||||
"""每 IP 每分钟请求数,0 关闭(默认 120)。"""
|
||||
raw = os.environ.get("RATE_LIMIT")
|
||||
return 120 if raw is None or raw == "" else int(raw)
|
||||
@@ -0,0 +1 @@
|
||||
"""服务端横切能力:错误、鉴权、限流、流签名、咪咕解密代理。"""
|
||||
@@ -0,0 +1,29 @@
|
||||
"""API 业务错误 —— 路由处理中抛出,由全局异常处理器转换为对应 HTTP 状态码的 JSON 响应。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ApiError(Exception):
|
||||
"""业务错误:status 同时作为响应体中的 code。"""
|
||||
|
||||
def __init__(self, status: int, msg: str, detail: Any = None) -> None:
|
||||
super().__init__(msg)
|
||||
self.status = status
|
||||
self.code = status
|
||||
self.detail = detail
|
||||
|
||||
|
||||
def not_found(msg: str, detail: Any = None) -> ApiError:
|
||||
"""资源不存在(未知路由 / 不支持的平台)。"""
|
||||
return ApiError(404, msg, detail)
|
||||
|
||||
|
||||
def upstream(msg: str, detail: Any = None) -> ApiError:
|
||||
"""上游平台请求失败。"""
|
||||
return ApiError(502, msg, detail)
|
||||
|
||||
|
||||
def bad_request(msg: str, detail: Any = None) -> ApiError:
|
||||
return ApiError(400, msg, detail)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)。
|
||||
|
||||
验证头统一为 X-Server-Key;全部路径(含文档页/健康检查/404)都要求秘钥,
|
||||
未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
|
||||
CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
|
||||
|
||||
/stream/* 流代理例外:播放器/下载器直接 GET 媒体地址、无法携带自定义头,
|
||||
改用短时签名(?exp=&sig=,见 stream_sign.py)——不落裸秘钥到 URL/日志,
|
||||
签名泄露仅能在时效窗口内拉当条流;能带头的客户端仍可用 X-Server-Key。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.http.stream_sign import verify_stream_sign
|
||||
|
||||
|
||||
def _safe_equal(a: str, b: str) -> bool:
|
||||
return hmac.compare_digest(a.encode(), b.encode())
|
||||
|
||||
|
||||
async def auth_gate(request: Request, call_next):
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
auth_key = getattr(request.app.state, "auth_key", "") or ""
|
||||
if not auth_key: # 未配置秘钥:完全不鉴权
|
||||
return await call_next(request)
|
||||
|
||||
pathname = request.url.path
|
||||
header = request.headers.get("x-server-key", "")
|
||||
|
||||
if pathname.startswith("/stream/"):
|
||||
query = request.query_params
|
||||
ok = (header and _safe_equal(header, auth_key)) or (
|
||||
not header and verify_stream_sign(auth_key, pathname, query.get("exp"), query.get("sig"))
|
||||
)
|
||||
if ok:
|
||||
return await call_next(request)
|
||||
return JSONResponse(
|
||||
{"code": 401, "msg": "无效或缺少调用秘钥:X-Server-Key 头,或 /url 返回流地址自带的 exp/sig 签名"},
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
if not header or not _safe_equal(header, auth_key):
|
||||
# 统一 401 文案(不区分路径存在与否,防扫描探测)
|
||||
return JSONResponse(
|
||||
{"code": 401, "msg": "无效或缺少调用秘钥:请在 X-Server-Key 请求头携带"},
|
||||
status_code=401,
|
||||
)
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""统一成功信封 { code: 0, data }。
|
||||
|
||||
独立成模块是为了避免 routes → main → routes 的循环导入。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class Envelope(JSONResponse):
|
||||
"""成功响应:{ "code": 0, "data": <payload> }。"""
|
||||
|
||||
def render(self, content: Any) -> bytes:
|
||||
return json.dumps({"code": 0, "data": content}, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def ok(data: Any, status_code: int = 200) -> Envelope:
|
||||
return Envelope(data, status_code=status_code)
|
||||
@@ -0,0 +1,195 @@
|
||||
"""咪咕加密流 —— 票据登记与 Z3D 流式解密(对应原 src/http/mg-stream.ts + sdk/mg/util.ts 解密部分)。
|
||||
|
||||
统一入口为 /stream/:source/:id,source=mg 时 id 即此处签发的短票据。
|
||||
不把上游 URL 塞进播放地址(path 参数限长,且 URL 含时效签名与中文路径),
|
||||
票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
|
||||
|
||||
Z3D 解密算法(与原始 TS 一致):
|
||||
streamKey = MD5(AES128CBC(AndroidAppKey, ENCRYPTED_APP_CODE) + fileKey) 的大写十六进制(32 字节 ASCII)
|
||||
plain[i] = (cipher[i] - streamKey[i % 32]) & 0xFF
|
||||
密文可能以 3 字节 `Z3D` 魔数开头,需先剥离;已解密的数据(音频魔数开头)原样透传。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
TTL_SECONDS = 10 * 60
|
||||
STREAM_CACHE_LIMIT = 200
|
||||
MIGU_STREAM_UA = "Android_migu/7.41.13 okhttp/3.12.13"
|
||||
|
||||
# 咪咕 Android 客户端的 app key 与加密后的 app code(服务端下发,非机密凭据)
|
||||
_ANDROID_APP_KEY = b"308202333082019c"
|
||||
_ENCRYPTED_APP_CODE = bytes(
|
||||
[
|
||||
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
|
||||
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
|
||||
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
|
||||
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
|
||||
]
|
||||
) # fmt: skip
|
||||
_Z3D_IV = bytes(range(16))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MiguStreamTicket:
|
||||
url: str
|
||||
file_key: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_tickets: dict[str, MiguStreamTicket] = {}
|
||||
|
||||
|
||||
def _prune() -> None:
|
||||
now = time.time()
|
||||
for key in [k for k, t in _tickets.items() if now - t.created_at > TTL_SECONDS]:
|
||||
_tickets.pop(key, None)
|
||||
# 超出容量时按插入顺序淘汰最旧票据
|
||||
while len(_tickets) > STREAM_CACHE_LIMIT:
|
||||
oldest = next(iter(_tickets), None)
|
||||
if oldest is None:
|
||||
break
|
||||
_tickets.pop(oldest, None)
|
||||
|
||||
|
||||
def register_migu_stream(url: str, file_key: str) -> str:
|
||||
"""登记一条加密流,返回可直接放入 /stream/mg/:id 的短票据 id。"""
|
||||
_prune()
|
||||
ticket_id = uuid.uuid4().hex
|
||||
_tickets[ticket_id] = MiguStreamTicket(url=url, file_key=file_key, created_at=time.time())
|
||||
return ticket_id
|
||||
|
||||
|
||||
def take_migu_stream(ticket_id: str) -> MiguStreamTicket | None:
|
||||
"""取出票据(过期返回 None)。"""
|
||||
ticket = _tickets.get(ticket_id)
|
||||
if not ticket:
|
||||
return None
|
||||
if time.time() - ticket.created_at > TTL_SECONDS:
|
||||
_tickets.pop(ticket_id, None)
|
||||
return None
|
||||
return ticket
|
||||
|
||||
|
||||
def is_migu_audio_magic(data: bytes) -> bool:
|
||||
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
|
||||
if len(data) < 4:
|
||||
return False
|
||||
head4 = data[:4]
|
||||
if head4 in (b"fLaC", b"OggS"):
|
||||
return True
|
||||
if head4 == b"RIFF" and len(data) >= 12 and data[8:12] == b"WAVE":
|
||||
return True
|
||||
if data[:3] == b"ID3":
|
||||
return True
|
||||
if len(data) >= 12 and data[4:8] == b"ftyp":
|
||||
return True
|
||||
return data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
|
||||
|
||||
|
||||
def migu_z3d_stream_key(file_key: str) -> bytes:
|
||||
"""streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制(32 字节 ASCII)。"""
|
||||
key = str(file_key or "").strip()
|
||||
if not key:
|
||||
raise RuntimeError("空的 z3d fileKey")
|
||||
# Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,这里同样禁用填充,否则尾块校验失败
|
||||
decryptor = Cipher(algorithms.AES(_ANDROID_APP_KEY), modes.CBC(_Z3D_IV)).decryptor()
|
||||
app_code = decryptor.update(_ENCRYPTED_APP_CODE) + decryptor.finalize()
|
||||
digest = hashlib.md5(app_code + key.encode("utf-8")).hexdigest().upper()
|
||||
return digest.encode("ascii")
|
||||
|
||||
|
||||
def decrypt_z3d(data: bytes, file_key: str) -> bytes:
|
||||
"""整段解密(非流式场景;已是明文则原样返回)。"""
|
||||
if not data:
|
||||
raise RuntimeError("空的 z3d 数据")
|
||||
if is_migu_audio_magic(data):
|
||||
return data
|
||||
payload = data[3:] if data[:3] == b"Z3D" else data
|
||||
if not payload:
|
||||
raise RuntimeError("剥离 Z3D 魔数后无数据")
|
||||
stream_key = migu_z3d_stream_key(file_key)
|
||||
plain = bytes((byte - stream_key[index & 31]) & 0xFF for index, byte in enumerate(payload))
|
||||
if not is_migu_audio_magic(plain):
|
||||
raise RuntimeError("z3d 解密后不是有效音频")
|
||||
return plain
|
||||
|
||||
|
||||
class Z3DStreamDecoder:
|
||||
"""按块增量解密:跨块保持全局偏移,首块剥离 `Z3D` 魔数并校验结果。"""
|
||||
|
||||
def __init__(self, file_key: str) -> None:
|
||||
self._stream_key = migu_z3d_stream_key(file_key)
|
||||
self._offset = 0
|
||||
self._pending = b"" # 首块不足 3 字节时暂存,用于判断 `Z3D` 魔数
|
||||
self._checked = False
|
||||
|
||||
def feed(self, chunk: bytes) -> bytes:
|
||||
if not chunk:
|
||||
return b""
|
||||
data = self._pending + chunk
|
||||
self._pending = b""
|
||||
if self._offset == 0 and len(data) < 3:
|
||||
self._pending = data
|
||||
return b""
|
||||
if self._offset == 0 and data[:3] == b"Z3D":
|
||||
data = data[3:]
|
||||
base = self._offset
|
||||
self._offset += len(data)
|
||||
plain = bytes((byte - self._stream_key[(base + index) & 31]) & 0xFF for index, byte in enumerate(data))
|
||||
if not self._checked and plain:
|
||||
self._checked = True
|
||||
if not is_migu_audio_magic(plain):
|
||||
raise RuntimeError("z3d 解密后不是有效音频")
|
||||
return plain
|
||||
|
||||
|
||||
def migu_stream_content_type(url: str) -> str:
|
||||
"""按扩展名推断明文的 content-type。"""
|
||||
if ".flac" in url:
|
||||
return "audio/flac"
|
||||
if ".wav" in url:
|
||||
return "audio/wav"
|
||||
if ".m4a" in url:
|
||||
return "audio/mp4"
|
||||
return "audio/mpeg"
|
||||
|
||||
|
||||
async def stream_decrypted(
|
||||
url: str,
|
||||
feed: Callable[[bytes], bytes],
|
||||
on_chunk: Callable[[bytes], None],
|
||||
*,
|
||||
max_redirects: int = 3,
|
||||
) -> None:
|
||||
"""流式下载密流并逐块解密后交给 on_chunk。
|
||||
|
||||
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
|
||||
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=300, sock_read=60)
|
||||
headers = {"User-Agent": MIGU_STREAM_UA, "Referer": "https://music.migu.cn/"}
|
||||
current = url
|
||||
for _ in range(max_redirects + 1):
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(current, headers=headers, allow_redirects=False) as resp:
|
||||
if 300 <= resp.status < 400 and (location := resp.headers.get("location")):
|
||||
current = urljoin(current, location)
|
||||
continue
|
||||
if resp.status != 200:
|
||||
raise RuntimeError(f"上游返回 {resp.status}")
|
||||
async for chunk in resp.content.iter_any():
|
||||
plain = feed(chunk)
|
||||
if plain:
|
||||
on_chunk(plain)
|
||||
return
|
||||
raise RuntimeError("重定向次数过多")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""全局限流 —— per-IP 滑动窗口(内存实现,无额外依赖)。
|
||||
|
||||
RATE_LIMIT 环境变量:每 IP 每分钟允许的请求数(默认 120;设 0 关闭)。
|
||||
在鉴权之前计数(401 爆破秘钥同样受限);超限返回 429 + Retry-After。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
WINDOW_SECONDS = 60.0
|
||||
|
||||
|
||||
def make_rate_limiter(limit_per_minute: int):
|
||||
hits: dict[str, list[float]] = {}
|
||||
last_cleanup = 0.0
|
||||
|
||||
async def middleware(request: Request, call_next):
|
||||
nonlocal last_cleanup
|
||||
if request.method == "OPTIONS" or limit_per_minute <= 0:
|
||||
return await call_next(request)
|
||||
|
||||
now = time.monotonic()
|
||||
# 惰性清理:每窗口顺带删除已过期的 IP 记录,防内存无限增长
|
||||
if now - last_cleanup > WINDOW_SECONDS:
|
||||
threshold = now - WINDOW_SECONDS
|
||||
for ip in [ip for ip, times in hits.items() if not times or times[-1] <= threshold]:
|
||||
hits.pop(ip, None)
|
||||
last_cleanup = now
|
||||
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
times = [t for t in hits.get(ip, []) if t > now - WINDOW_SECONDS]
|
||||
times.append(now)
|
||||
hits[ip] = times
|
||||
if len(times) > limit_per_minute:
|
||||
retry_after = max(1, int(times[0] + WINDOW_SECONDS - now) + 1)
|
||||
return JSONResponse(
|
||||
{"code": 429, "msg": f"请求过于频繁(限 {limit_per_minute} 次/分钟),请 {retry_after} 秒后重试"},
|
||||
status_code=429,
|
||||
headers={"Retry-After": str(retry_after)},
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
return middleware
|
||||
@@ -0,0 +1,38 @@
|
||||
"""流代理签名 —— /stream/* 的无头客户端兼容方案。
|
||||
|
||||
/url 返回流地址时附加 `?exp=<秒级时间戳>&sig=<HMAC-SHA256(authKey, "path:exp") 前 32 位>`。
|
||||
与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import time
|
||||
|
||||
# 流地址有效期(秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
|
||||
STREAM_SIGN_TTL = 10 * 60
|
||||
|
||||
|
||||
def _sign(auth_key: str, data: str) -> str:
|
||||
return hmac.new(auth_key.encode(), data.encode(), hashlib.sha256).hexdigest()[:32]
|
||||
|
||||
|
||||
def sign_stream(auth_key: str, path: str) -> str:
|
||||
"""生成流地址的签名参数(path 为不含 query 的路径,如 /stream/mg/123)。"""
|
||||
exp = int(time.time() + STREAM_SIGN_TTL)
|
||||
return f"exp={exp}&sig={_sign(auth_key, f'{path}:{exp}')}"
|
||||
|
||||
|
||||
def verify_stream_sign(auth_key: str, path: str, exp: object, sig: object) -> bool:
|
||||
"""校验流地址签名(常量时间比较 + 时效窗口)。"""
|
||||
try:
|
||||
exp_num = int(float(str(exp)))
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
sig_str = str(sig or "")
|
||||
if not sig_str:
|
||||
return False
|
||||
if time.time() > exp_num: # 过期即拒(客户端重新 /url 取新地址)
|
||||
return False
|
||||
return hmac.compare_digest(_sign(auth_key, f"{path}:{exp_num}"), sig_str)
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
"""FastAPI 应用装配 —— 中间件、异常处理、OpenAPI 文档、路由注册。
|
||||
|
||||
与 Node 版语义对齐:
|
||||
* 成功响应统一包成 { code: 0, data }(由路由处理函数调用 `ok()` 显式构造,
|
||||
文档页、OpenAPI 规范与 /stream 媒体流为 raw 响应)
|
||||
* 业务错误 { code: <HTTP 状态码>, msg, detail? }
|
||||
* 422 参数校验失败、404 路由或平台不存在、405 方法不允许(带 Allow 头)、502 上游失败
|
||||
* 全部响应 Cache-Control: no-store
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app import config
|
||||
from app.http.api_error import ApiError
|
||||
from app.http.auth_gate import auth_gate
|
||||
from app.http.rate_limit import make_rate_limiter
|
||||
from app.openapi.components import COMPONENT_SCHEMAS
|
||||
from app.openapi.ui import redoc_html
|
||||
|
||||
API_TITLE = "MusicServer 聚合音乐 API"
|
||||
API_DESCRIPTION = (
|
||||
"多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。"
|
||||
"支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)/ bd(千千)/ "
|
||||
"joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 "
|
||||
"xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。"
|
||||
)
|
||||
|
||||
|
||||
class Envelope(JSONResponse):
|
||||
"""统一成功响应:{ code: 0, data }。文档里由路由的 responses 引用领域模型。"""
|
||||
|
||||
def render(self, content: Any) -> bytes:
|
||||
return json.dumps({"code": 0, "data": content}, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def ok(data: Any, status_code: int = 200) -> Envelope:
|
||||
return Envelope(data, status_code=status_code)
|
||||
|
||||
|
||||
def make_router(tag: str) -> APIRouter:
|
||||
return APIRouter(tags=[tag])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
"""启动期副作用 —— 登录态自动续期(启动后延时首刷 + 每 24h 一次,与 TS 版一致)。"""
|
||||
from app.routes.refresh import start_refresh_scheduler, stop_refresh_scheduler
|
||||
|
||||
start_refresh_scheduler()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
await stop_refresh_scheduler()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
config.load_env()
|
||||
|
||||
app = FastAPI(
|
||||
title=API_TITLE,
|
||||
description=API_DESCRIPTION,
|
||||
version="1.0.0",
|
||||
lifespan=_lifespan,
|
||||
openapi_url="/openapi.json",
|
||||
docs_url="/docs",
|
||||
redoc_url=None,
|
||||
openapi_tags=[
|
||||
{"name": "搜索", "description": "歌曲搜索与跨平台匹配"},
|
||||
{"name": "歌曲资源", "description": "播放地址、歌词、封面"},
|
||||
{"name": "榜单", "description": "排行榜"},
|
||||
{"name": "歌单", "description": "歌单与热搜"},
|
||||
{"name": "歌手专辑", "description": "歌手信息、歌曲与专辑详情"},
|
||||
{"name": "登录", "description": "账号登录与状态"},
|
||||
{"name": "系统", "description": "健康检查、平台信息、文档"},
|
||||
],
|
||||
)
|
||||
app.state.started_at = time.time()
|
||||
app.state.auth_key = config.auth_key()
|
||||
|
||||
# ---- 中间行为:限流 → 鉴权(限流先计数,401 爆破同样受限) ----
|
||||
app.middleware("http")(make_rate_limiter(config.rate_limit()))
|
||||
app.middleware("http")(auth_gate)
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_store(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
# ---- 异常处理 ----
|
||||
@app.exception_handler(ApiError)
|
||||
async def _api_error(_: Request, exc: ApiError):
|
||||
payload: dict[str, Any] = {"code": exc.status, "msg": str(exc)}
|
||||
if exc.detail is not None:
|
||||
payload["detail"] = exc.detail
|
||||
return JSONResponse(payload, status_code=exc.status)
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def _validation_error(_: Request, exc: RequestValidationError):
|
||||
errors = json.loads(json.dumps(exc.errors(), default=str))
|
||||
msg = "参数校验失败"
|
||||
if errors:
|
||||
first = errors[0]
|
||||
loc = ".".join(str(part) for part in first.get("loc", []) if part not in ("body", "query", "path"))
|
||||
msg = f"{loc}: {first.get('msg', msg)}" if loc else str(first.get("msg", msg))
|
||||
return JSONResponse({"code": 422, "msg": msg, "detail": errors}, status_code=422)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def _http_error(request: Request, exc: StarletteHTTPException):
|
||||
if exc.status_code == 404:
|
||||
allowed = _allowed_methods(request)
|
||||
if allowed:
|
||||
return JSONResponse(
|
||||
{"code": 405, "msg": f"方法 {request.method} 不允许,请使用 {', '.join(allowed)}"},
|
||||
status_code=405,
|
||||
headers={"Allow": ", ".join(allowed)},
|
||||
)
|
||||
return JSONResponse({"code": 404, "msg": f"接口不存在: {request.url.path}"}, status_code=404)
|
||||
detail = exc.detail if isinstance(exc.detail, str) else "请求失败"
|
||||
return JSONResponse({"code": exc.status_code, "msg": detail}, status_code=exc.status_code)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled(request: Request, exc: Exception):
|
||||
print(f"[{time.strftime('%Y-%m-%dT%H:%M:%S')}] {request.method} {request.url.path} 内部错误: {exc!r}")
|
||||
return JSONResponse({"code": 500, "msg": "服务器内部错误"}, status_code=500)
|
||||
|
||||
# ---- 文档与入口 ----
|
||||
@app.get("/redoc", include_in_schema=False)
|
||||
async def redoc():
|
||||
return HTMLResponse(redoc_html(API_TITLE))
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def root():
|
||||
return RedirectResponse("/docs", status_code=302)
|
||||
|
||||
# ---- 业务路由 ----
|
||||
from app.routes.login import router as login_router
|
||||
from app.routes.music import router as music_router
|
||||
from app.routes.system import router as system_router
|
||||
|
||||
app.include_router(music_router)
|
||||
app.include_router(login_router)
|
||||
app.include_router(system_router)
|
||||
|
||||
_install_openapi(app)
|
||||
return app
|
||||
|
||||
|
||||
def _allowed_methods(request: Request) -> list[str]:
|
||||
"""路径存在但方法不允许 → 405 的 Allow 列表(OPTIONS/HEAD 不计入)。"""
|
||||
path = request.url.path
|
||||
methods: list[str] = []
|
||||
for route in request.app.routes:
|
||||
pattern = getattr(route, "path", "")
|
||||
if not pattern or not _match_path(pattern, path):
|
||||
continue
|
||||
for method in getattr(route, "methods", None) or []:
|
||||
if method not in {"OPTIONS", "HEAD"} and method not in methods:
|
||||
methods.append(method)
|
||||
return methods
|
||||
|
||||
|
||||
def _match_path(pattern: str, path: str) -> bool:
|
||||
"""把 `/url/{source}/{id}` 这类路径模板转成正则做匹配。"""
|
||||
regex = re.sub(r"\{[^}]+\}", "\u0000", pattern)
|
||||
regex = re.escape(regex).replace("\u0000", "[^/]+")
|
||||
return re.fullmatch(regex, path) is not None
|
||||
|
||||
|
||||
def _install_openapi(app: FastAPI) -> None:
|
||||
"""把领域模型挂进 components,并保留 FastAPI 自动生成的路径参数文档。"""
|
||||
from fastapi.openapi.utils import get_openapi
|
||||
|
||||
def custom_openapi():
|
||||
if app.openapi_schema:
|
||||
return app.openapi_schema
|
||||
schema = get_openapi(title=API_TITLE, version="1.0.0", description=API_DESCRIPTION, routes=app.routes)
|
||||
components = schema.setdefault("components", {}).setdefault("schemas", {})
|
||||
for name, definition in COMPONENT_SCHEMAS.items():
|
||||
components.setdefault(name, definition)
|
||||
app.openapi_schema = schema
|
||||
return schema
|
||||
|
||||
app.openapi = custom_openapi # type: ignore[method-assign]
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,394 @@
|
||||
"""OpenAPI 领域模型 —— 注册为 FastAPI 共享 components schema。
|
||||
|
||||
路由的 response 通过 `{$ref: "#/components/schemas/名称"}` 引用。
|
||||
注:additionalProperties: true 保证序列化时不剥离未声明字段。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def ref(name: str) -> dict[str, Any]:
|
||||
return {"$ref": f"#/components/schemas/{name}"}
|
||||
|
||||
|
||||
COMPONENT_SCHEMAS: dict[str, Any] = {
|
||||
"ErrorEnvelope": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "integer", "description": "与 HTTP 状态码一致", "example": 422},
|
||||
"msg": {"type": "string", "description": "错误描述"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
"required": ["code", "msg"],
|
||||
},
|
||||
"QualityInfo": {
|
||||
"type": "object",
|
||||
"properties": {"size": {"type": "string", "example": "9.8M"}},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SongType": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string", "example": "320k", "enum": ["128k", "320k", "flac", "flac24bit"]},
|
||||
"size": {"type": "string", "example": "9.8M"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SongInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"songmid": {"type": "string", "description": "平台歌曲 id(kg 平台为 hash)"},
|
||||
"hash": {"type": "string", "description": "kg 平台的歌曲 hash"},
|
||||
"copyrightId": {"type": "string", "description": "平台版权 id(原 mg 平台使用)"},
|
||||
"albumId": {"type": "string"},
|
||||
"name": {"type": "string", "description": "歌名"},
|
||||
"singer": {"type": "string", "description": "歌手"},
|
||||
"source": {"type": "string", "description": "来源平台 id"},
|
||||
"interval": {"type": "string", "description": "时长,格式 mm:ss"},
|
||||
"albumName": {"type": "string"},
|
||||
"img": {"type": ["string", "null"], "description": "封面地址"},
|
||||
"types": {"type": "array", "items": ref("SongType"), "description": "可用音质列表"},
|
||||
"_types": {"type": "object", "additionalProperties": ref("QualityInfo")},
|
||||
"typeUrl": {"type": "object"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SearchResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string", "description": "来源平台 id,聚合搜索时为 all"},
|
||||
"list": {"type": "array", "items": ref("SongInfo")},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"FindResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list": {"type": "array", "items": ref("SongInfo"), "description": "按匹配度排序的歌曲列表"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"MusicUrlResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"type": "string", "example": "320k", "description": "实际命中的音质(降级链可能低于请求音质)"},
|
||||
"url": {"type": "string", "description": "播放地址"},
|
||||
"via": {
|
||||
"type": "string",
|
||||
"example": "kw",
|
||||
"description": "实际命中通道:平台 id / kw=酷我兜底;直接命中时不返回该字段",
|
||||
},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"LyricInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"lyric": {"type": "string", "description": "原文歌词(LRC)"},
|
||||
"tlyric": {"type": "string", "description": "翻译歌词"},
|
||||
"rlyric": {"type": "string", "description": "罗马音歌词(部分平台)"},
|
||||
"yrc": {"type": "string", "description": "逐字歌词(部分平台)"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
"required": ["lyric", "tlyric"],
|
||||
},
|
||||
"PicResult": {
|
||||
"type": "object",
|
||||
"properties": {"url": {"type": "string", "description": "封面地址"}},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"BoardItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "榜单 id,用于榜单详情接口"},
|
||||
"name": {"type": "string"},
|
||||
"bangid": {"type": "string", "description": "平台原始榜单 id"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"BoardListResult": {
|
||||
"type": "object",
|
||||
"properties": {"list": {"type": "array", "items": ref("BoardItem")}},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SingerInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "歌手名"},
|
||||
"desc": {"type": ["string", "null"], "description": "歌手简介"},
|
||||
"avatar": {"type": ["string", "null"], "description": "头像地址"},
|
||||
"gender": {"type": ["string", "null"], "description": "性别(man/woman)"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SingerInfoResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"id": {"type": "string", "description": "平台歌手 id"},
|
||||
"info": ref("SingerInfo"),
|
||||
"count": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"music": {"type": ["integer", "null"], "description": "歌曲数"},
|
||||
"album": {"type": ["integer", "null"], "description": "专辑数"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SingerAlbumItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "专辑 id,可用于 /album/:source/:id"},
|
||||
"mid": {"type": "string", "description": "tx 平台专辑 mid"},
|
||||
"count": {"type": ["integer", "null"], "description": "专辑曲目数"},
|
||||
"info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"author": {"type": ["string", "null"]},
|
||||
"img": {"type": ["string", "null"]},
|
||||
"desc": {"type": ["string", "null"]},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SingerAlbumListResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"list": {"type": "array", "items": ref("SingerAlbumItem")},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
"total": {"type": ["integer", "string", "null"]},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SingerSongListResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"list": {"type": "array", "items": ref("SongInfo")},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
"total": {"type": ["integer", "string", "null"]},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"AlbumInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": ["string", "null"], "description": "专辑名"},
|
||||
"img": {"type": ["string", "null"], "description": "专辑封面"},
|
||||
"desc": {"type": ["string", "null"], "description": "专辑简介"},
|
||||
"author": {"type": ["string", "null"], "description": "专辑作者"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"AlbumDetailResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"info": ref("AlbumInfo"),
|
||||
"list": {"type": "array", "items": ref("SongInfo"), "description": "专辑曲目"},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": ["integer", "string", "null"]},
|
||||
"total": {"type": ["integer", "string", "null"]},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SongItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"singer": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"albumName": {"type": "string"},
|
||||
"albumId": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
"interval": {"type": "string"},
|
||||
"songmid": {"type": "string"},
|
||||
"hash": {"type": "string"},
|
||||
"img": {"type": "string"},
|
||||
"types": {"type": "array", "items": ref("SongType")},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"BoardDetailResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list": {"type": "array", "items": ref("SongItem")},
|
||||
"total": {"type": "integer"},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"HotSearchResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"list": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SongListSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"play_count": {"type": "string", "description": "播放量(已格式化)"},
|
||||
"id": {"type": "string", "description": "歌单 id"},
|
||||
"author": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"time": {"type": "string"},
|
||||
"img": {"type": "string"},
|
||||
"total": {"type": "integer", "description": "歌曲数"},
|
||||
"desc": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SongListResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list": {"type": "array", "items": ref("SongListSummary")},
|
||||
"total": {"type": "integer"},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SourceInfo": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "example": "kw"},
|
||||
"name": {"type": "string", "example": "酷我音乐"},
|
||||
"qualities": {"type": "array", "items": {"type": "string"}, "description": "支持的音质"},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"search": {"type": "boolean"},
|
||||
"url": {"type": "boolean"},
|
||||
"lyric": {"type": "boolean"},
|
||||
"pic": {"type": "boolean"},
|
||||
"boards": {"type": "boolean"},
|
||||
"hotSearch": {"type": "boolean"},
|
||||
"songList": {"type": "boolean"},
|
||||
"singer": {"type": "boolean", "description": "支持歌手接口(/singer/:source/:id)"},
|
||||
"album": {"type": "boolean", "description": "支持专辑接口(/album/:source/:id)"},
|
||||
"albumSearch": {"type": "boolean", "description": "支持专辑搜索(/album/:source/search/:keyword)"},
|
||||
"playlist": {"type": "boolean", "description": "支持歌单详情(/playlist/:source/:id)"},
|
||||
"playlistSearch": {"type": "boolean", "description": "支持歌单搜索"},
|
||||
"playlistCategories": {"type": "boolean", "description": "支持歌单分类"},
|
||||
"parse": {"type": "boolean", "description": "支持链接解析(/parse/:source)"},
|
||||
"userPlaylist": {"type": "boolean", "description": "支持个人歌单(/user-playlist/:source)"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"SourcesResult": {
|
||||
"type": "object",
|
||||
"properties": {"platforms": {"type": "array", "items": ref("SourceInfo")}},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"PlaylistSummary": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "歌单/专辑 id"},
|
||||
"name": {"type": "string"},
|
||||
"cover": {"type": "string"},
|
||||
"trackCount": {"type": "integer"},
|
||||
"playCount": {"type": "integer"},
|
||||
"creator": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"link": {"type": "string"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"PlaylistSearchResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list": {"type": "array", "items": ref("PlaylistSummary")},
|
||||
"total": {"type": "integer"},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"PlaylistDetailResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"info": ref("AlbumInfo"),
|
||||
"list": {"type": "array", "items": ref("SongItem")},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": ["integer", "string", "null"]},
|
||||
"total": {"type": ["integer", "string", "null"]},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"PlaylistCategoriesResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"list": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
"group": {"type": "string"},
|
||||
"hot": {"type": "boolean"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"ParseResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source": {"type": "string"},
|
||||
"type": {"type": "string", "description": "解析类型:song / album / playlist"},
|
||||
"song": ref("SongItem"),
|
||||
"playlist": ref("PlaylistSummary"),
|
||||
"list": {"type": "array", "items": ref("SongItem"), "description": "album/playlist 解析时的曲目"},
|
||||
"total": {"type": "integer"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"UserPlaylistResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"list": {"type": "array", "items": ref("PlaylistSummary")},
|
||||
"page": {"type": "integer"},
|
||||
"limit": {"type": "integer"},
|
||||
"total": {"type": "integer"},
|
||||
"source": {"type": "string"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"HealthResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {"type": "string", "example": "ok"},
|
||||
"uptime": {"type": "integer", "description": "运行秒数"},
|
||||
"platforms": {"type": "integer", "description": "已接入平台数"},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""ReDoc 文档页。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
REDOC_CDN = "https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"
|
||||
|
||||
|
||||
def redoc_html(title: str, spec_url: str = "/openapi.json") -> str:
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{title} (ReDoc)</title>
|
||||
<style>body {{ margin: 0; padding: 0; }}</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="{spec_url}" hide-download-button="true"></redoc>
|
||||
<script src="{REDOC_CDN}"></script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -0,0 +1,395 @@
|
||||
"""登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/密码/token,tx 扫码/cookie,
|
||||
xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json。
|
||||
|
||||
成功返回 `ok(data)`({ code: 0, data });可预期失败抛 ApiError,其它异常按 502 上报
|
||||
(与原实现 `fail(reply, err)` 的 `err.status ?? 502` 一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.http.api_error import ApiError, upstream
|
||||
from app.http.envelope import ok
|
||||
from app.routes.refresh import refresh_all
|
||||
from app.sdk import auth as auth_mod
|
||||
from app.sdk.auth_store import clear_credentials, get_credentials, get_tx_auth, set_credentials
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
LOGIN_TAG = "登录"
|
||||
|
||||
|
||||
async def _call(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""调用平台登录实现并包成信封;ApiError 原样抛(保留状态码),其余转 502。"""
|
||||
try:
|
||||
return ok(await fn(*args, **kwargs))
|
||||
except ApiError:
|
||||
raise
|
||||
except NotImplementedError as err:
|
||||
raise ApiError(501, f"该登录方式尚未移植:{err}") from err
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
def _body(description: str) -> Any:
|
||||
# embed=True:与 TS 契约一致,所有登录接口都以 JSON 对象传参({"key": ...}),
|
||||
# 而不是把单个参数当成裸 JSON 值。多参数时 FastAPI 本就自动嵌套,行为不变。
|
||||
return Body(..., embed=True, description=description)
|
||||
|
||||
|
||||
# ---------- 网易云 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy/qr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="获取网易云扫码登录二维码",
|
||||
description="用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。",
|
||||
)
|
||||
async def wy_qr() -> Any:
|
||||
return await _call(auth_mod.wy.qr_key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy/qr/check",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="轮询网易云扫码状态",
|
||||
description="code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。",
|
||||
)
|
||||
async def wy_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
|
||||
return await _call(auth_mod.wy.qr_check, key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy/cellphone",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="网易云手机号登录",
|
||||
description="password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。",
|
||||
)
|
||||
async def wy_cellphone(
|
||||
phone: str = _body("手机号"),
|
||||
password: str | None = Body(None, description="明文密码"),
|
||||
captcha: str | None = Body(None, description="短信验证码"),
|
||||
countrycode: str = Body("86", description="国家码"),
|
||||
) -> Any:
|
||||
return await _call(auth_mod.wy.cellphone_login, phone, password, captcha, countrycode)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定网易云 cookie",
|
||||
description="cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。"
|
||||
"绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。",
|
||||
)
|
||||
async def wy_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||||
return await _call(auth_mod.wy.login_by_cookie, cookie)
|
||||
|
||||
|
||||
@router.delete("/login/wy", tags=[LOGIN_TAG], summary="退出网易云登录")
|
||||
async def wy_logout() -> Any:
|
||||
clear_credentials("wy")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 酷狗 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/kg",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定酷狗 token",
|
||||
description="token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。"
|
||||
"推荐改用 /login/kg/qr 扫码或 /login/kg/cellphone 短信验证码登录,无需抓包。",
|
||||
)
|
||||
async def kg_bind(token: str = _body("酷狗 token"), userid: str | int = _body("酷狗 userid")) -> Any:
|
||||
return await _call(auth_mod.kg.login_by_token, token, userid)
|
||||
|
||||
|
||||
@router.post("/login/kg/qr", tags=[LOGIN_TAG], summary="获取酷狗扫码登录二维码")
|
||||
async def kg_qr() -> Any:
|
||||
return await _call(auth_mod.kg.qr_key)
|
||||
|
||||
|
||||
@router.post("/login/kg/qr/check", tags=[LOGIN_TAG], summary="轮询酷狗扫码状态")
|
||||
async def kg_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
|
||||
return await _call(auth_mod.kg.qr_check, key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/kg/captcha",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="发送酷狗短信验证码",
|
||||
description="向手机号发送登录验证码(有频控,勿频繁调用),随后用 /login/kg/cellphone 提交。",
|
||||
)
|
||||
async def kg_captcha(mobile: str = _body("手机号")) -> Any:
|
||||
return await _call(auth_mod.kg.send_captcha, mobile)
|
||||
|
||||
|
||||
@router.post("/login/kg/cellphone", tags=[LOGIN_TAG], summary="酷狗手机验证码登录")
|
||||
async def kg_cellphone(mobile: str = _body("手机号"), code: str = _body("短信验证码")) -> Any:
|
||||
return await _call(auth_mod.kg.cellphone_login, mobile, code)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/kg/password",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="酷狗账号密码登录",
|
||||
description="触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):"
|
||||
"滑块类需浏览器完成图形验证后调 /login/kg/verify 再重试;短信类(v_type=32)先提交短信码再带 code 重试。",
|
||||
)
|
||||
async def kg_password(
|
||||
username: str = _body("账号"),
|
||||
password: str = _body("密码"),
|
||||
code: str | None = Body(None, description="短信验证码(20028 短信验证通过后重试登录时携带)"),
|
||||
) -> Any:
|
||||
return await _call(auth_mod.kg.password_login, username, password, code)
|
||||
|
||||
|
||||
@router.post("/login/kg/verify", tags=[LOGIN_TAG], summary="提交酷狗风控验证结果")
|
||||
async def kg_verify(
|
||||
eventid: str = _body("needVerify 返回的 eventid"),
|
||||
v_type: int = _body("验证类型(needVerify 返回的 v_type)"),
|
||||
verifycode: str = _body("图形验证码凭证或短信验证码"),
|
||||
sid: str | None = Body(None, description="行为指纹 sid(省略时服务端生成)"),
|
||||
edt: str | None = Body(None, description="行为指纹 edt(省略时服务端生成)"),
|
||||
) -> Any:
|
||||
return await _call(auth_mod.kg.verify_user_info, eventid, v_type, verifycode, sid, edt)
|
||||
|
||||
|
||||
@router.delete("/login/kg", tags=[LOGIN_TAG], summary="退出酷狗登录")
|
||||
async def kg_logout() -> Any:
|
||||
clear_credentials("kg")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- QQ 音乐 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/tx/qr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="获取 QQ 音乐扫码登录二维码",
|
||||
description="返回 qrimg(data URL)可直接展示,qrsig 需原样回传。",
|
||||
)
|
||||
async def tx_qr() -> Any:
|
||||
return await _call(auth_mod.tx.qr_show)
|
||||
|
||||
|
||||
@router.post("/login/tx/qr/check", tags=[LOGIN_TAG], summary="轮询 QQ 音乐扫码状态")
|
||||
async def tx_qr_check(qrsig: str = _body("qrShow 返回的 qrsig")) -> Any:
|
||||
return await _call(auth_mod.tx.qr_check, qrsig)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/tx",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定 QQ 音乐 cookie",
|
||||
description="cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),"
|
||||
"用于解锁 tx 取流的 320k/无损音质。",
|
||||
)
|
||||
async def tx_bind(
|
||||
cookie: str = _body("浏览器 cookie"),
|
||||
openId: str | None = Body(None, description="App 抓包的 openId(用于自动续期)"),
|
||||
accessToken: str | None = Body(None, description="App 抓包的 accessToken"),
|
||||
refreshToken: str | None = Body(None, description="App 抓包的 refreshToken"),
|
||||
refreshKey: str | None = Body(None, description="App 抓包的 refreshKey"),
|
||||
) -> Any:
|
||||
import re
|
||||
|
||||
match = re.search(r"uin=(\d+)", cookie)
|
||||
if not match or not re.search(r"qm_keyst=\S+", cookie):
|
||||
raise ApiError(422, "cookie 需包含 uin 与 qm_keyst 字段")
|
||||
uin = match.group(1)
|
||||
set_credentials(
|
||||
"tx",
|
||||
{
|
||||
"cookie": cookie,
|
||||
"uin": uin,
|
||||
"openId": openId,
|
||||
"accessToken": accessToken,
|
||||
"refreshToken": refreshToken,
|
||||
"refreshKey": refreshKey,
|
||||
},
|
||||
)
|
||||
return ok({"logged": True, "uin": uin})
|
||||
|
||||
|
||||
@router.delete("/login/tx", tags=[LOGIN_TAG], summary="退出 QQ 音乐登录")
|
||||
async def tx_logout() -> Any:
|
||||
clear_credentials("tx")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 喜马拉雅 / 猫耳 / 懒人听书 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/xm",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定喜马拉雅 cookie",
|
||||
description="cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段)。",
|
||||
)
|
||||
async def xm_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||||
import re
|
||||
|
||||
if not re.search(r"1&_token=\S+", cookie):
|
||||
raise ApiError(422, "cookie 需包含 1&_token 字段")
|
||||
set_credentials("xm", {"cookie": cookie})
|
||||
return ok({"logged": True})
|
||||
|
||||
|
||||
@router.delete("/login/xm", tags=[LOGIN_TAG], summary="退出喜马拉雅登录")
|
||||
async def xm_logout() -> Any:
|
||||
clear_credentials("xm")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/me",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定猫耳FM token",
|
||||
description="cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段)。",
|
||||
)
|
||||
async def me_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||||
import re
|
||||
|
||||
if not re.search(r"(?:^|;\s*)token=\S+", cookie):
|
||||
raise ApiError(422, "cookie 需包含 token 字段")
|
||||
set_credentials("me", {"cookie": cookie})
|
||||
return ok({"logged": True})
|
||||
|
||||
|
||||
@router.delete("/login/me", tags=[LOGIN_TAG], summary="退出猫耳FM登录")
|
||||
async def me_logout() -> Any:
|
||||
clear_credentials("me")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/lr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="懒人听书账号登录",
|
||||
description="m.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化,"
|
||||
"此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。",
|
||||
)
|
||||
async def lr_login(
|
||||
account: str = _body("账号(手机号)"), password: str = _body("密码(明文传入,服务端自行编码)")
|
||||
) -> Any:
|
||||
return await _call(auth_mod.lr.login, account, password)
|
||||
|
||||
|
||||
@router.delete("/login/lr", tags=[LOGIN_TAG], summary="退出懒人听书登录")
|
||||
async def lr_logout() -> Any:
|
||||
clear_credentials("lr")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 哔哩哔哩 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/bili/qr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="获取哔哩哔哩扫码登录二维码",
|
||||
description="用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL。",
|
||||
)
|
||||
async def bili_qr() -> Any:
|
||||
return await _call(auth_mod.bili.create_qr_login)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/bili/qr/check",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="轮询哔哩哔哩扫码状态",
|
||||
description="status:waiting / scanned / success(自动保存凭据)/ expired / failed。"
|
||||
"code 为映射后的状态码:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(与其它平台一致)。",
|
||||
)
|
||||
async def bili_qr_check(key: str = _body("createQRLogin 返回的 key")) -> Any:
|
||||
return await _call(auth_mod.bili.check_qr_login, key)
|
||||
|
||||
|
||||
@router.delete("/login/bili", tags=[LOGIN_TAG], summary="退出哔哩哔哩登录")
|
||||
async def bili_logout() -> Any:
|
||||
clear_credentials("bili")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 通用 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/refresh",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="手动触发登录态续期",
|
||||
description="对已登录的 kg/tx 执行 token/cookie 续期(与自动任务同一逻辑)。"
|
||||
"未登录的平台跳过;失败的平台在对应字段返回 error,不影响其他平台。",
|
||||
)
|
||||
async def login_refresh() -> Any:
|
||||
return ok(await refresh_all())
|
||||
|
||||
|
||||
def _stored_status(package: str) -> dict[str, Any]:
|
||||
"""按已存凭据判断登录态(xm/me/bd/joox/apple/5sing 用)。"""
|
||||
cred = get_credentials(package)
|
||||
if not cred:
|
||||
return {"logged": False}
|
||||
return {"logged": True, "updatedAt": cred.get("updatedAt")}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/login/status",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="查看登录状态",
|
||||
description="wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据"
|
||||
"(含 VIP 等级与最近更新时间),xm/me/lr/bd/joox/apple/5sing 为已存凭据。",
|
||||
)
|
||||
async def login_status() -> Any:
|
||||
|
||||
async def safe_wy() -> dict[str, Any]:
|
||||
try:
|
||||
result = await auth_mod.wy.status()
|
||||
return {"logged": True, **result} if result else {"logged": False}
|
||||
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
|
||||
return {"logged": False}
|
||||
|
||||
async def safe_kg() -> dict[str, Any]:
|
||||
cred = get_credentials("kg")
|
||||
if not cred or not cred.get("token"):
|
||||
return {"logged": False}
|
||||
try:
|
||||
result = await auth_mod.kg.status()
|
||||
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
|
||||
result = None
|
||||
payload = {**(result or {}), "updatedAt": cred.get("updatedAt")}
|
||||
return {"logged": True, **payload}
|
||||
|
||||
wy_result = await safe_wy()
|
||||
tx = get_tx_auth()
|
||||
tx_cred = get_credentials("tx") or {}
|
||||
return ok(
|
||||
{
|
||||
"wy": wy_result,
|
||||
"kg": await safe_kg(),
|
||||
"tx": (
|
||||
{
|
||||
"logged": True,
|
||||
"uin": tx["uin"],
|
||||
"vipType": tx_cred.get("vipType", None),
|
||||
"updatedAt": tx_cred.get("updatedAt"),
|
||||
}
|
||||
if tx
|
||||
else {"logged": False}
|
||||
),
|
||||
"xm": _stored_status("xm"),
|
||||
"me": _stored_status("me"),
|
||||
"lr": _stored_status("lr"),
|
||||
"bili": _stored_status("bili"),
|
||||
"bd": _stored_status("bd"),
|
||||
"joox": _stored_status("joox"),
|
||||
"apple": _stored_status("apple"),
|
||||
"5sing": _stored_status("5sing"),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,869 @@
|
||||
"""业务路由 —— 搜索、取流、歌词、封面、榜单、歌单、歌手专辑、链接解析。
|
||||
|
||||
处理函数返回 `ok(data)`,由 main.Envelope 统一包成 { code: 0, data };
|
||||
抛出 ApiError 由全局异常处理器转成对应 HTTP 状态码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.http.api_error import not_found, upstream
|
||||
from app.http.envelope import ok
|
||||
from app.http.mg_stream import (
|
||||
Z3DStreamDecoder,
|
||||
migu_stream_content_type,
|
||||
register_migu_stream,
|
||||
stream_decrypted,
|
||||
take_migu_stream,
|
||||
)
|
||||
from app.http.stream_sign import sign_stream
|
||||
from app.sdk import SOURCE_MODULES, SUPPORTED_SOURCES, find_music
|
||||
from app.sdk.types import cap as get_cap
|
||||
from fastapi import APIRouter, Body, Path, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
QUALITIES = ["64k", "128k", "320k", "flac", "flac24bit"]
|
||||
|
||||
# ---- 常用 OpenAPI 响应片段 ----
|
||||
ENVELOPE_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "integer", "description": "0 表示成功", "example": 0},
|
||||
"data": {"description": "业务数据"},
|
||||
},
|
||||
"required": ["code", "data"],
|
||||
}
|
||||
|
||||
|
||||
def envelope_ref(name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "integer", "description": "0 表示成功", "example": 0},
|
||||
"data": {"$ref": f"#/components/schemas/{name}"},
|
||||
},
|
||||
"required": ["code", "data"],
|
||||
}
|
||||
|
||||
|
||||
ERROR_ENVELOPE = {"$ref": "#/components/schemas/ErrorEnvelope"}
|
||||
|
||||
|
||||
def responses_for(
|
||||
data_ref: str, *, not_found: bool = True, upstream_doc: bool = True
|
||||
) -> dict[int | str, dict[str, Any]]:
|
||||
docs: dict[int | str, dict[str, Any]] = {
|
||||
"200": {"description": "成功", "content": {"application/json": {"schema": envelope_ref(data_ref)}}},
|
||||
"422": {"description": "参数校验失败", "content": {"application/json": {"schema": ERROR_ENVELOPE}}},
|
||||
}
|
||||
if not_found:
|
||||
docs["404"] = {"description": "接口或平台不存在", "content": {"application/json": {"schema": ERROR_ENVELOPE}}}
|
||||
if upstream_doc:
|
||||
docs["502"] = {"description": "上游平台失败", "content": {"application/json": {"schema": ERROR_ENVELOPE}}}
|
||||
return docs
|
||||
|
||||
|
||||
def get_source(source: str, capability: str | None = None) -> Any:
|
||||
"""取平台模块(按能力位校验),不存在或无该能力 → 404。"""
|
||||
module = SOURCE_MODULES.get(source)
|
||||
if module is None or get_cap(module, capability) is None:
|
||||
raise not_found(f"不支持的平台: {source}")
|
||||
return module
|
||||
|
||||
|
||||
def platform_path(source: str, capability: str | None = None, description: str = "平台 id(见 /sources)") -> Any:
|
||||
return Path(..., description=f"{description}{f',需支持 {capability} 能力' if capability else ''}", example="kw")
|
||||
|
||||
|
||||
# ---------- 搜索 ----------
|
||||
|
||||
|
||||
async def _search_handler(source: str, keyword: str, page: int, singer: str | None, limit: int) -> Any:
|
||||
word = f"{keyword} {singer or ''}".strip()
|
||||
if source != "all":
|
||||
get_source(source, "music_search")
|
||||
result = await search_platform(source, word, page, limit)
|
||||
return {"source": source, "list": result, "page": page, "limit": limit}
|
||||
|
||||
# 聚合:排除有声平台,失败的平台跳过
|
||||
import asyncio
|
||||
|
||||
targets = [
|
||||
item["id"]
|
||||
for item in SUPPORTED_SOURCES
|
||||
if getattr(item["module"], "music_search", None) is not None and not item.get("no_aggregate")
|
||||
]
|
||||
|
||||
async def one(platform: str):
|
||||
try:
|
||||
return await search_platform(platform, word, page, limit)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
lists = await asyncio.gather(*(one(platform) for platform in targets))
|
||||
return {"source": "all", "list": [song for chunk in lists for song in chunk], "page": page, "limit": limit}
|
||||
|
||||
|
||||
async def search_platform(platform: str, word: str, page: int, limit: int) -> list[dict[str, Any]]:
|
||||
try:
|
||||
result = await SOURCE_MODULES[platform].music_search.search(word, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
return result.get("list") or []
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/{source}/{keyword}",
|
||||
summary="搜索歌曲(第 1 页)",
|
||||
description="source 为平台 id 或 all(聚合,排除 xm/me/qt/lr 有声平台,失败的平台跳过)。"
|
||||
"path 直接跟搜索内容,第 1 页可省略页码段;歌名含空格/斜杠时请拆分——歌手走 singer 查询参数。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("SearchResult", not_found=False),
|
||||
)
|
||||
async def search_first(
|
||||
source: str = platform_path("kw", "search"),
|
||||
keyword: str = Path(..., description="搜索关键词(URL 编码的中文可直接使用)", example="稻香"),
|
||||
singer: str | None = Query(None, description="歌手名(与关键词拼接搜索)", example="周杰伦"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return ok(await _search_handler(source, keyword, 1, singer, limit))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/{source}/{keyword}/{page}",
|
||||
summary="搜索歌曲(指定页)",
|
||||
description="同 /search/:source/:keyword,页码作为 path 段。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("SearchResult", not_found=False),
|
||||
)
|
||||
async def search_page(
|
||||
source: str = platform_path("kw", "search"),
|
||||
keyword: str = Path(..., description="搜索关键词", example="稻香"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
singer: str | None = Query(None, description="歌手名"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return ok(await _search_handler(source, keyword, page, singer, limit))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{source}/{keyword}",
|
||||
summary="精确匹配歌曲",
|
||||
description="按歌手、歌名、专辑、时长综合排序的精确匹配。source 为平台 id(只在单平台内匹配)或 all"
|
||||
"(跨平台聚合,可用 excludeSource 跳过某平台;聚合自动排除 xm/me/qt/lr 有声平台)。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("FindResult", not_found=False),
|
||||
)
|
||||
async def find(
|
||||
source: str = Path(..., description="平台 id 或 all", example="all"),
|
||||
keyword: str = Path(..., description="歌名", example="稻香"),
|
||||
singer: str | None = Query(None, description="歌手名", example="周杰伦"),
|
||||
albumName: str | None = Query(None, description="专辑名", example="魔杰座"),
|
||||
interval: str | None = Query(None, description="时长(mm:ss),用于时长校验", example="03:43"),
|
||||
excludeSource: str | None = Query(None, description="source=all 时跳过该平台", example="kw"),
|
||||
) -> Any:
|
||||
kwargs: dict[str, Any] = {
|
||||
"name": keyword,
|
||||
"singer": singer,
|
||||
"album_name": albumName,
|
||||
"interval": interval,
|
||||
}
|
||||
if source != "all":
|
||||
kwargs["only"] = source
|
||||
elif excludeSource:
|
||||
kwargs["source"] = excludeSource
|
||||
try:
|
||||
return ok({"list": await find_music(**kwargs)})
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
# ---------- 歌曲资源 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/url/{source}/{id}",
|
||||
summary="获取播放地址",
|
||||
description="id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,xm/me 用声音 id,qt 为 <channelId>:<programId>"
|
||||
"(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。"
|
||||
"取流按「SDK 官方直连 → 酷我兜底」顺序尝试,酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),"
|
||||
"kg 可由 hash 反查歌曲信息,其它平台需要兜底时传 name/singer/interval。"
|
||||
"响应中的 via 字段标记实际命中通道(仅兜底时出现)。",
|
||||
tags=["歌曲资源"],
|
||||
responses=responses_for("MusicUrlResult"),
|
||||
)
|
||||
async def music_url(
|
||||
request: Request,
|
||||
source: str = platform_path("kw", "url"),
|
||||
id: str = Path(..., description="平台歌曲 id", example="28423286"),
|
||||
quality: str = Query("128k", description="音质"),
|
||||
albumId: str | None = Query(None, description="专辑 id"),
|
||||
name: str | None = Query(None, description="歌名(酷我兜底匹配用)"),
|
||||
singer: str | None = Query(None, description="歌手名(酷我兜底匹配用)"),
|
||||
interval: str | None = Query(None, description="时长 mm:ss(酷我兜底匹配用)", example="04:17"),
|
||||
) -> Any:
|
||||
if quality not in QUALITIES:
|
||||
from app.http.api_error import ApiError
|
||||
|
||||
raise ApiError(422, f"quality: 不支持的音质 {quality}(可选:{', '.join(QUALITIES)})")
|
||||
|
||||
module = get_source(source, "get_music_url")
|
||||
song_info = {"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or "", "_types": {}}
|
||||
|
||||
def finish(result: dict[str, Any], via: str) -> dict[str, Any]:
|
||||
if not result or not result.get("url"):
|
||||
raise RuntimeError("获取播放地址失败")
|
||||
raw_url = str(result["url"])
|
||||
if result.get("encrypted") and result.get("fileKey"):
|
||||
raw_url = f"/stream/mg/{register_migu_stream(raw_url, str(result['fileKey']))}"
|
||||
# 相对地址按请求的协议与 host 补全(兼容本机/局域网播放器)
|
||||
if raw_url.startswith("/"):
|
||||
suffix = ""
|
||||
auth_key = getattr(request.app.state, "auth_key", "")
|
||||
if auth_key:
|
||||
path = raw_url.split("?")[0]
|
||||
suffix = f"{'&' if '?' in raw_url else '?'}{sign_stream(auth_key, path)}"
|
||||
raw_url = f"{request.url.scheme}://{request.headers.get('host', '')}{raw_url}{suffix}"
|
||||
payload: dict[str, Any] = {"type": result.get("type") or quality, "url": raw_url}
|
||||
if via != source:
|
||||
payload["via"] = via
|
||||
return payload
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
return ok(finish(await module.get_music_url(song_info, quality), source))
|
||||
except Exception as err: # noqa: BLE001
|
||||
errors.append(f"{source}: {err}")
|
||||
|
||||
# 酷我兜底:匿名可拿真无损的通道,按歌名/歌手/时长匹配同一首歌后取流
|
||||
if source != "kw":
|
||||
try:
|
||||
meta: dict[str, Any] | None = {"name": name, "singer": singer, "interval": interval} if name else None
|
||||
if not meta and source == "kg":
|
||||
# kg 可用 hash 反查歌曲信息,无需调用方额外传参
|
||||
kg_module = SOURCE_MODULES.get("kg")
|
||||
lookup = getattr(kg_module, "get_music_info", None)
|
||||
info = await lookup({"hash": id, "songmid": id}) if lookup else None
|
||||
if info and info.get("name"):
|
||||
meta = {
|
||||
"name": str(info["name"]),
|
||||
"singer": str(info["singer"]) if info.get("singer") else None,
|
||||
"interval": str(info["interval"]) if info.get("interval") else None,
|
||||
}
|
||||
if meta and meta.get("name"):
|
||||
best_list = await find_music(
|
||||
name=str(meta["name"]),
|
||||
singer=meta.get("singer"),
|
||||
interval=meta.get("interval"),
|
||||
only="kw",
|
||||
)
|
||||
best = best_list[0] if best_list else None
|
||||
if best:
|
||||
kw_module = SOURCE_MODULES["kw"]
|
||||
res = await kw_module.get_music_url(
|
||||
{"songmid": best["songmid"], "hash": best["songmid"], "_types": {}}, quality
|
||||
)
|
||||
return ok(finish(res, "kw"))
|
||||
errors.append("kw 兜底: 未匹配到同名歌曲")
|
||||
else:
|
||||
errors.append("kw 兜底: 缺少歌名信息(传 name/singer/interval 查询参数可启用)")
|
||||
except Exception as err: # noqa: BLE001
|
||||
errors.append(f"kw 兜底: {err}")
|
||||
|
||||
raise upstream(f"取流失败({';'.join(errors)})")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stream/{source}/{id}",
|
||||
summary="加密流解密代理(mg)",
|
||||
description="取流响应中返回的相对地址(/url/mg 命中加密流时的地址),服务端下载上游密流并在本地解密后返回明文音频;"
|
||||
"不接受客户端自行构造。id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。",
|
||||
tags=["歌曲资源"],
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def stream(source: str, id: str) -> Any:
|
||||
if source != "mg":
|
||||
raise not_found(f"不支持的加密流平台: {source}")
|
||||
ticket = take_migu_stream(str(id))
|
||||
if not ticket:
|
||||
raise not_found("解密票据不存在或已过期(请重新调用 /url/mg 取流)")
|
||||
return _stream_response(ticket.url, Z3DStreamDecoder(ticket.file_key))
|
||||
|
||||
|
||||
def _stream_response(url: str, decoder: Z3DStreamDecoder) -> StreamingResponse:
|
||||
"""异步队列桥接:下载协程生产解密块,StreamingResponse 消费块。
|
||||
|
||||
解密按块增量进行(跨块保持全局偏移),整段音频可达上百 MB,不能读进内存。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=32)
|
||||
|
||||
async def produce() -> None:
|
||||
try:
|
||||
await stream_decrypted(url, decoder.feed, queue.put_nowait)
|
||||
except Exception as err: # noqa: BLE001 - 响应头已发出,只能中断连接`r`n # 日志留痕,只能中断连接;把错误留在服务端日志
|
||||
print(f"[stream] 上游读取中断: {err!r}")
|
||||
finally:
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def body():
|
||||
task = asyncio.create_task(produce())
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type=migu_stream_content_type(url),
|
||||
headers={"Accept-Ranges": "none"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/lyric/{source}/{id}",
|
||||
summary="获取歌词",
|
||||
tags=["歌曲资源"],
|
||||
responses=responses_for("LyricInfo"),
|
||||
)
|
||||
async def lyric(
|
||||
source: str = platform_path("kw", "lyric"),
|
||||
id: str = Path(..., description="平台歌曲 id"),
|
||||
name: str | None = Query(None, description="歌名(部分平台搜索歌词时需要)"),
|
||||
interval: str | None = Query(None, description="时长(mm:ss),部分平台搜索歌词时需要"),
|
||||
) -> Any:
|
||||
module = get_source(source, "get_lyric")
|
||||
try:
|
||||
result = await module.get_lyric(
|
||||
{"songmid": id, "hash": id, "copyrightId": id, "name": name or "", "interval": interval or ""}
|
||||
)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
return ok(result or {"lyric": "", "tlyric": ""})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pic/{source}/{id}",
|
||||
summary="获取封面",
|
||||
tags=["歌曲资源"],
|
||||
responses=responses_for("PicResult"),
|
||||
)
|
||||
async def pic(
|
||||
source: str = platform_path("kw", "pic"),
|
||||
id: str = Path(..., description="平台歌曲 id"),
|
||||
albumId: str | None = Query(None, description="专辑 id(kg 平台需要)"),
|
||||
) -> Any:
|
||||
module = get_source(source, "get_pic")
|
||||
try:
|
||||
url = await module.get_pic({"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or ""})
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
return ok({"url": url})
|
||||
|
||||
|
||||
# ---------- 榜单 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{source}",
|
||||
summary="获取榜单列表",
|
||||
tags=["榜单"],
|
||||
responses=responses_for("BoardListResult"),
|
||||
)
|
||||
async def boards(source: str = platform_path("kw", "boards")) -> Any:
|
||||
module = get_source(source, "leaderboard")
|
||||
try:
|
||||
return ok(await module.leaderboard.get_boards())
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
async def _board_handler(source: str, board_id: str, page: int) -> Any:
|
||||
module = get_source(source, "leaderboard")
|
||||
# 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化
|
||||
bare = board_id[len(f"{source}__") :] if board_id.startswith(f"{source}__") else board_id
|
||||
try:
|
||||
return ok(await module.leaderboard.get_list(bare, page))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{source}/{boardId}",
|
||||
summary="获取榜单详情",
|
||||
tags=["榜单"],
|
||||
responses=responses_for("BoardDetailResult"),
|
||||
)
|
||||
async def board_first(
|
||||
source: str = platform_path("kw", "boards"),
|
||||
boardId: str = Path(
|
||||
..., description="榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)", example="kw__93"
|
||||
),
|
||||
) -> Any:
|
||||
return await _board_handler(source, boardId, 1)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{source}/{boardId}/{page}",
|
||||
summary="获取榜单详情(指定页)",
|
||||
tags=["榜单"],
|
||||
responses=responses_for("BoardDetailResult"),
|
||||
)
|
||||
async def board_page(
|
||||
source: str = platform_path("kw", "boards"),
|
||||
boardId: str = Path(..., description="榜单 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
) -> Any:
|
||||
return await _board_handler(source, boardId, page)
|
||||
|
||||
|
||||
# ---------- 热搜 & 歌单列表 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/hot-search/{source}",
|
||||
summary="获取热搜词",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("HotSearchResult"),
|
||||
)
|
||||
async def hot_search(source: str = platform_path("kw", "hot_search")) -> Any:
|
||||
module = get_source(source, "hot_search")
|
||||
try:
|
||||
return ok(await module.hot_search.get_list())
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
async def _song_list_handler(source: str, page: int, sort_id: Any, tag_id: Any) -> Any:
|
||||
module = get_source(source, "song_list")
|
||||
sort_default = module.song_list.sort_list[0]["id"] if getattr(module.song_list, "sort_list", None) else None
|
||||
try:
|
||||
return ok(await module.song_list.get_list(sort_id if sort_id is not None else sort_default, tag_id, page))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/song-list/{source}",
|
||||
summary="获取歌单列表",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("SongListResult"),
|
||||
)
|
||||
async def song_list_first(
|
||||
source: str = platform_path("kw", "song_list"),
|
||||
sortId: str | None = Query(None, description="排序 id(平台相关;省略时用平台默认)"),
|
||||
tagId: str | None = Query(None, description="标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)"),
|
||||
) -> Any:
|
||||
return await _song_list_handler(source, 1, sortId, tagId)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/song-list/{source}/{page}",
|
||||
summary="获取歌单列表(指定页)",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("SongListResult"),
|
||||
)
|
||||
async def song_list_page(
|
||||
source: str = platform_path("kw", "song_list"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
sortId: str | None = Query(None, description="排序 id"),
|
||||
tagId: str | None = Query(None, description="标签 id"),
|
||||
) -> Any:
|
||||
return await _song_list_handler(source, page, sortId, tagId)
|
||||
|
||||
|
||||
# ---------- 歌手 & 专辑 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}",
|
||||
summary="获取歌手信息",
|
||||
description="支持 kg/tx/wy(见 /sources 的 singer 能力位)。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerInfoResult"),
|
||||
)
|
||||
async def singer_info(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
) -> Any:
|
||||
module = get_source(source, "singer")
|
||||
try:
|
||||
return ok(await module.singer.get_info(id))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/songs",
|
||||
summary="获取歌手歌曲列表",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerSongListResult"),
|
||||
)
|
||||
async def singer_songs_first(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_songs(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/songs/{page}",
|
||||
summary="获取歌手歌曲列表(指定页)",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerSongListResult"),
|
||||
)
|
||||
async def singer_songs_page(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_songs(source, id, page, limit)
|
||||
|
||||
|
||||
async def _singer_songs(source: str, singer_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "singer")
|
||||
try:
|
||||
return ok(await module.singer.get_song_list(singer_id, page, limit))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/albums",
|
||||
summary="获取歌手专辑列表",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerAlbumListResult"),
|
||||
)
|
||||
async def singer_albums_first(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_albums(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/albums/{page}",
|
||||
summary="获取歌手专辑列表(指定页)",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerAlbumListResult"),
|
||||
)
|
||||
async def singer_albums_page(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_albums(source, id, page, limit)
|
||||
|
||||
|
||||
async def _singer_albums(source: str, singer_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "singer")
|
||||
try:
|
||||
return ok(await module.singer.get_album_list(singer_id, page, limit))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
async def _album_detail(source: str, album_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "album")
|
||||
album = module.album
|
||||
# 各平台适配器签名不同:kg 为 get_album_detail(id, page, limit),
|
||||
# kw 为 get_album_list_detail(id, page)——第三参数是 retryNum,不能透传 limit
|
||||
try:
|
||||
if hasattr(album, "get_album_detail"):
|
||||
return ok(await album.get_album_detail(album_id, page, limit))
|
||||
return ok(await album.get_album_list_detail(album_id, page))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/album/{source}/{id}",
|
||||
summary="获取专辑详情与曲目列表",
|
||||
description="支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple(见 /sources 的 album 能力位)。"
|
||||
"kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("AlbumDetailResult"),
|
||||
)
|
||||
async def album_first(
|
||||
source: str = platform_path("kg", "album"),
|
||||
id: str = Path(..., description="平台专辑 id(搜索结果中的 albumId)", example="123456"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _album_detail(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/album/{source}/{id}/{page}",
|
||||
summary="获取专辑详情与曲目列表(指定页)",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("AlbumDetailResult"),
|
||||
)
|
||||
async def album_page(
|
||||
source: str = platform_path("kg", "album"),
|
||||
id: str = Path(..., description="平台专辑 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _album_detail(source, id, page, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/album/{source}/search/{keyword}",
|
||||
summary="搜索专辑",
|
||||
description="支持 kg/kw/tx/wy/mg/bd/joox/apple(见 /sources 的 albumSearch 能力位)。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("AlbumDetailResult", not_found=False),
|
||||
)
|
||||
async def album_search(
|
||||
source: str = platform_path("kg", "album.search_album"),
|
||||
keyword: str = Path(..., description="专辑名关键词", example="魔杰座"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
module = get_source(source, "album.search_album")
|
||||
try:
|
||||
res = await module.album.search_album(keyword, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
if isinstance(res, list):
|
||||
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
|
||||
return ok({"source": source, **res})
|
||||
|
||||
|
||||
# ---------- 歌单 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/search/{keyword}",
|
||||
summary="搜索歌单",
|
||||
description="支持 kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。"
|
||||
"部分平台该能力受限(如 bd 歌单搜索接口已下线、bili 需登录),失败时返回 502。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistSearchResult"),
|
||||
)
|
||||
async def playlist_search(
|
||||
source: str = platform_path("kw", "playlist.search_playlist"),
|
||||
keyword: str = Path(..., description="歌单关键词", example="华语"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
module = get_source(source, "playlist.search_playlist")
|
||||
try:
|
||||
res = await module.playlist.search_playlist(keyword, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
if isinstance(res, list):
|
||||
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
|
||||
return ok({"source": source, **res})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/categories",
|
||||
summary="获取歌单分类",
|
||||
description="支持 kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistCategoriesResult"),
|
||||
)
|
||||
async def playlist_categories(source: str = platform_path("kw", "playlist.get_categories")) -> Any:
|
||||
module = get_source(source, "playlist.get_categories")
|
||||
try:
|
||||
res = await module.playlist.get_categories()
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
# 歌单分类返回形状不一({list}/裸数组),统一为 { source, list }
|
||||
items = res if isinstance(res, list) else (res or {}).get("list") or []
|
||||
return ok({"source": source, "list": items})
|
||||
|
||||
|
||||
async def _category_playlists(source: str, category_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "playlist")
|
||||
if not hasattr(module.playlist, "get_category_playlists"):
|
||||
raise not_found(f"平台 {source} 不支持分类歌单")
|
||||
try:
|
||||
res = await module.playlist.get_category_playlists(category_id, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
if isinstance(res, list):
|
||||
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
|
||||
return ok({"source": source, **res})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/categories/{categoryId}",
|
||||
summary="获取分类下的歌单",
|
||||
description="categoryId 为 /playlist/:source/categories 返回的分类 id(部分平台 id 即分类名)。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistSearchResult"),
|
||||
)
|
||||
async def playlist_category_items(
|
||||
source: str = platform_path("kw", "playlist.get_categories"),
|
||||
categoryId: str = Path(..., description="分类 id(分类列表返回)", example="华语"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _category_playlists(source, categoryId, page, limit)
|
||||
|
||||
|
||||
async def _playlist_detail(source: str, playlist_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "playlist")
|
||||
try:
|
||||
return ok(await module.playlist.get_playlist_detail(playlist_id, page, limit))
|
||||
except Exception as err: # noqa: BLE001
|
||||
# 部分平台详情签名只有 id:退化为取全量后本地切片
|
||||
try:
|
||||
songs = await module.playlist.get_playlist_songs(playlist_id)
|
||||
if not isinstance(songs, list):
|
||||
raise err
|
||||
start = (page - 1) * limit
|
||||
return ok(
|
||||
{
|
||||
"source": source,
|
||||
"info": {"name": playlist_id, "img": "", "desc": "", "author": ""},
|
||||
"list": songs[start : start + limit],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": len(songs),
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/{id}",
|
||||
summary="获取歌单详情与曲目列表",
|
||||
description="id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:<id>:<mid>:<bvid>、"
|
||||
"joox/bd/mg 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistDetailResult"),
|
||||
)
|
||||
async def playlist_first(
|
||||
source: str = platform_path("kw", "playlist"),
|
||||
id: str = Path(..., description="歌单 id(或平台歌单链接)", example="123456789"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _playlist_detail(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/{id}/{page}",
|
||||
summary="获取歌单详情与曲目列表(指定页)",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistDetailResult"),
|
||||
)
|
||||
async def playlist_page(
|
||||
source: str = platform_path("kw", "playlist"),
|
||||
id: str = Path(..., description="歌单 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _playlist_detail(source, id, page, limit)
|
||||
|
||||
|
||||
# ---------- 链接解析 ----------
|
||||
|
||||
|
||||
async def _parse_handler(kind: str, source: str, link: str) -> Any:
|
||||
module = get_source(source, "parse")
|
||||
impl = getattr(module.parse, f"parse_{kind}", None)
|
||||
if impl is None:
|
||||
raise not_found(f"平台 {source} 不支持解析该类型链接")
|
||||
try:
|
||||
out = await impl(link)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
if kind == "song":
|
||||
if out and (out.get("list") or out.get("songs")):
|
||||
songs = out.get("list") or out.get("songs") or []
|
||||
return ok(
|
||||
{
|
||||
"source": source,
|
||||
"type": "playlist",
|
||||
"playlist": out.get("info") or out.get("playlist"),
|
||||
"list": songs,
|
||||
"total": len(songs),
|
||||
}
|
||||
)
|
||||
return ok({"source": source, "type": "song", "song": out})
|
||||
|
||||
songs = (out or {}).get("list") or (out or {}).get("songs") or []
|
||||
return ok(
|
||||
{
|
||||
"source": source,
|
||||
"type": kind,
|
||||
"playlist": (out or {}).get("info") or (out or {}).get("playlist") or out,
|
||||
"list": songs,
|
||||
"total": len(songs),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_PARSE_KINDS = {"song": "歌曲", "album": "专辑", "playlist": "歌单"}
|
||||
|
||||
|
||||
def _register_parse_routes() -> None:
|
||||
for kind, label in _PARSE_KINDS.items():
|
||||
|
||||
async def handler(
|
||||
source: str = platform_path("kw", "parse.parse_song"),
|
||||
link: str = Body(..., embed=True, description="平台链接或裸 id"),
|
||||
_kind: str = kind,
|
||||
) -> Any:
|
||||
return await _parse_handler(_kind, source, str(link).strip())
|
||||
|
||||
router.add_api_route(
|
||||
f"/parse/{kind}/{{source}}",
|
||||
handler,
|
||||
methods=["POST"],
|
||||
summary=f"解析{label}链接",
|
||||
description="把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;"
|
||||
"5sing 歌曲/歌单、apple/bili/joox/bd/mg 三类、kw/kg/tx/wy 部分支持。",
|
||||
tags=["链接解析"],
|
||||
responses=responses_for("ParseResult"),
|
||||
name=f"parse_{kind}",
|
||||
)
|
||||
|
||||
|
||||
_register_parse_routes()
|
||||
|
||||
|
||||
# ---------- 个人歌单 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-playlist/{source}",
|
||||
summary="获取个人歌单列表",
|
||||
description="需先登录(见 /login/*)。当前仅 tx/wy 有完整实现(见 /sources 的 userPlaylist 能力位);"
|
||||
"未登录或平台不支持时返回 404/502。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("UserPlaylistResult"),
|
||||
)
|
||||
async def user_playlist(
|
||||
source: str = platform_path("wy", "user_playlist.get_list"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
module = get_source(source, "user_playlist.get_list")
|
||||
try:
|
||||
result = await module.user_playlist.get_list(page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
items = result if isinstance(result, list) else (result or {}).get("list") or []
|
||||
return ok({"source": source, "list": items, "page": page, "limit": limit})
|
||||
@@ -0,0 +1,77 @@
|
||||
"""登录态自动续期 —— 启动后延时刷一轮 + 每 24h 一次(对应原 src/refresh.ts)。
|
||||
|
||||
各平台凭据独立处理:未登录跳过,单个平台失败不影响另一个。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.auth import kg as kg_auth
|
||||
from app.sdk.auth import tx as tx_auth
|
||||
|
||||
INTERVAL_SECONDS = 24 * 60 * 60
|
||||
# 启动后延迟触发,避开启动期与冒烟测试的快速起停
|
||||
STARTUP_DELAY_SECONDS = 10
|
||||
|
||||
_running = False
|
||||
_tasks: list[asyncio.Task[None]] = []
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[{datetime.now().isoformat(timespec='milliseconds')}] [登录续期] {msg}")
|
||||
|
||||
|
||||
async def refresh_all() -> dict[str, Any]:
|
||||
"""手动/定时共用的续期入口,返回各平台结果(skipped / refreshed / error)。"""
|
||||
global _running
|
||||
if _running:
|
||||
return {"skipped": True}
|
||||
_running = True
|
||||
try:
|
||||
result: dict[str, Any] = {}
|
||||
for name, fn in (("kg", kg_auth.refresh_login), ("tx", tx_auth.refresh_login)):
|
||||
try:
|
||||
res = await fn()
|
||||
result[name] = res
|
||||
if isinstance(res, dict) and res.get("skipped"):
|
||||
_log(f"{name}: 未登录,跳过")
|
||||
else:
|
||||
_log(f"{name}: 登录态已刷新")
|
||||
except NotImplementedError:
|
||||
result[name] = {"skipped": True, "msg": "尚未移植"}
|
||||
except Exception as err: # noqa: BLE001 - 单平台失败不影响其它平台
|
||||
result[name] = {"error": str(err)}
|
||||
_log(f"{name}: {err}")
|
||||
return result
|
||||
finally:
|
||||
_running = False
|
||||
|
||||
|
||||
async def _loop() -> None:
|
||||
await asyncio.sleep(STARTUP_DELAY_SECONDS)
|
||||
while True:
|
||||
await refresh_all()
|
||||
await asyncio.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def start_refresh_scheduler() -> None:
|
||||
"""注册定时任务(在应用 startup 时调用)。"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError: # 无事件循环(如导入期)时忽略
|
||||
return
|
||||
task = loop.create_task(_loop())
|
||||
_tasks.append(task)
|
||||
_log(f"已启动自动续期(启动后 {STARTUP_DELAY_SECONDS}s 首刷,此后每 {INTERVAL_SECONDS // 3600}h 一次)")
|
||||
|
||||
|
||||
async def stop_refresh_scheduler() -> None:
|
||||
while _tasks:
|
||||
task = _tasks.pop()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -0,0 +1,51 @@
|
||||
"""系统路由 —— 健康检查与平台能力清单。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.http.envelope import ok
|
||||
from app.sdk import SUPPORTED_SOURCES, support_quality
|
||||
from app.sdk.platforms._base import capability_map
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["系统"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
summary="健康检查",
|
||||
description="返回运行时长与已接入平台数,可用于探活与监控。",
|
||||
)
|
||||
async def health(request: Request) -> Any:
|
||||
started_at = getattr(request.app.state, "started_at", None) or time.time()
|
||||
return ok(
|
||||
{
|
||||
"status": "ok",
|
||||
"uptime": round(time.time() - started_at),
|
||||
"platforms": len(SUPPORTED_SOURCES),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sources",
|
||||
summary="获取支持的平台",
|
||||
description="返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList/"
|
||||
"singer/album/albumSearch/playlist/playlistSearch/playlistCategories/parse/userPlaylist)。"
|
||||
"音质为 SDK 官方直连接口的支持范围。",
|
||||
)
|
||||
async def sources() -> Any:
|
||||
platforms: list[dict[str, Any]] = []
|
||||
for item in SUPPORTED_SOURCES:
|
||||
module = item["module"]
|
||||
platforms.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["name"],
|
||||
"qualities": support_quality.get(item["id"], []),
|
||||
"capabilities": capability_map(module),
|
||||
}
|
||||
)
|
||||
return ok({"platforms": platforms})
|
||||
@@ -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"),
|
||||
}
|
||||
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