feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+7
View File
@@ -42,3 +42,10 @@ coverage/
# 以下规则仅作保险,防止旧的发射产物被误生成后提交。 # 以下规则仅作保险,防止旧的发射产物被误生成后提交。
src/**/*.js src/**/*.js
sdk/**/*.js sdk/**/*.js
# Python(uv)
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.ruff_cache/
+1
View File
@@ -0,0 +1 @@
3.12
+12
View File
@@ -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"
+65
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""服务端横切能力:错误、鉴权、限流、流签名、咪咕解密代理。"""
+29
View File
@@ -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)
+55
View File
@@ -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)
+22
View File
@@ -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)
+195
View File
@@ -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("重定向次数过多")
+47
View File
@@ -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
+38
View File
@@ -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
View File
@@ -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()
+394
View File
@@ -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,
},
}
+21
View File
@@ -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>"""
+395
View File
@@ -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"),
}
)
+869
View File
@@ -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})
+77
View File
@@ -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
+51
View File
@@ -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})
+267
View File
@@ -0,0 +1,267 @@
"""SDK 聚合入口 —— 平台注册表、跨平台搜索与精确匹配。
对应原 sdk/index.ts。平台 id 对外为短码(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple),
sdk 目录与导入名使用平台名(bd → qian 目录历史已合并,见下)。
各平台包导出 `MODULE`(PlatformModule 实例),能力位按 snake_case 命名:
music_search / leaderboard / song_list / hot_search / comment / singer /
album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.utils import decode_name
# 通用工具函数(平台模块 `from app.sdk import format_play_time` 的解析目标)
__all__ = [
"SOURCE_MODULES",
"SUPPORTED_SOURCES",
"date_format",
"date_format2",
"decode_name",
"find_music",
"format_play_count",
"format_play_time",
"init",
"search_music",
"size_formate",
"sources",
"support_quality",
]
from app.sdk.common.utils import (
date_format,
date_format2,
format_play_count,
format_play_time,
size_formate,
)
_SINGERS_SEPARATOR = re.compile(r"、|&|;|;|/|,|,|\||\]|\[|!|!")
def _load_platforms() -> list[dict[str, Any]]:
"""按显示顺序加载平台包。导入失败即启动失败(与原 TS 静态导入一致)。"""
from app.sdk.platforms import apple, bd, bili, fivesing, joox, kg, kw, lr, me, mg, qt, tx, wy, xm
# no_aggregate:有声/播客类平台不参与聚合搜索与跨平台匹配,仅支持 source 直查
return [
{"id": "kw", "name": "酷我音乐", "module": kw.MODULE},
{"id": "kg", "name": "酷狗音乐", "module": kg.MODULE},
{"id": "tx", "name": "QQ音乐", "module": tx.MODULE},
{"id": "wy", "name": "网易音乐", "module": wy.MODULE},
{"id": "xm", "name": "喜马拉雅", "module": xm.MODULE, "no_aggregate": True},
{"id": "me", "name": "猫耳FM", "module": me.MODULE, "no_aggregate": True},
{"id": "qt", "name": "蜻蜓FM", "module": qt.MODULE, "no_aggregate": True},
{"id": "lr", "name": "懒人听书", "module": lr.MODULE, "no_aggregate": True},
{"id": "mg", "name": "咪咕音乐", "module": mg.MODULE},
{"id": "bd", "name": "千千音乐", "module": bd.MODULE},
{"id": "joox", "name": "JOOX", "module": joox.MODULE},
{"id": "bili", "name": "哔哩哔哩", "module": bili.MODULE},
{"id": "5sing", "name": "5sing", "module": fivesing.MODULE},
# Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
{"id": "apple", "name": "Apple Music", "module": apple.MODULE, "no_aggregate": True},
]
SUPPORTED_SOURCES: list[dict[str, Any]] = _load_platforms()
SOURCE_MODULES: dict[str, Any] = {item["id"]: item["module"] for item in SUPPORTED_SOURCES}
sources: list[dict[str, Any]] = [
{"id": item["id"], "name": item["name"], **({"noAggregate": True} if item.get("no_aggregate") else {})}
for item in SUPPORTED_SOURCES
]
def _quality_table() -> dict[str, list[str]]:
from app.sdk.apis import apple as apple_api
from app.sdk.apis import bd as bd_api
from app.sdk.apis import bili as bili_api
from app.sdk.apis import fivesing as fivesing_api
from app.sdk.apis import joox as joox_api
from app.sdk.apis import kg as kg_api
from app.sdk.apis import kw as kw_api
from app.sdk.apis import lr as lr_api
from app.sdk.apis import me as me_api
from app.sdk.apis import mg as mg_api
from app.sdk.apis import qt as qt_api
from app.sdk.apis import tx as tx_api
from app.sdk.apis import wy as wy_api
from app.sdk.apis import xm as xm_api
return {
"kw": kw_api.SUPPORT_QUALITYS,
"kg": kg_api.SUPPORT_QUALITYS,
"tx": tx_api.SUPPORT_QUALITYS,
"wy": wy_api.SUPPORT_QUALITYS,
"xm": xm_api.SUPPORT_QUALITYS,
"me": me_api.SUPPORT_QUALITYS,
"qt": qt_api.SUPPORT_QUALITYS,
"lr": lr_api.SUPPORT_QUALITYS,
"mg": mg_api.SUPPORT_QUALITYS,
"bd": bd_api.SUPPORT_QUALITYS,
"joox": joox_api.SUPPORT_QUALITYS,
"bili": bili_api.SUPPORT_QUALITYS,
"5sing": fivesing_api.SUPPORT_QUALITYS,
"apple": apple_api.SUPPORT_QUALITYS,
}
support_quality: dict[str, list[str]] = _quality_table()
async def init() -> None:
"""调用各平台的 init 钩子(无 init 的跳过;单个失败不阻塞启动,与原 init 一致)。"""
for item in SUPPORTED_SOURCES:
hook = getattr(item["module"], "init", None)
if hook is None:
continue
try:
await hook()
except Exception as err: # noqa: BLE001
print(f"[sdk] {item['id']} init 失败(已跳过): {err}")
def _trim_str(value: Any) -> Any:
return value.strip() if isinstance(value, str) else value
def _filter_str(value: Any) -> str:
if isinstance(value, str):
return re.sub(r"""\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||/|\]|\[|!|!""", "", value)
return "" if value is None else str(value)
def _get_interval_seconds(interval: Any) -> int:
if not interval:
return 0
parts = str(interval).split(":")
seconds = 0
unit = 1
while parts:
try:
seconds += int(parts.pop()) * unit
except ValueError:
pass
unit *= 60
return seconds
def _sort_single(singer: Any) -> str:
if not singer:
return ""
text = str(singer)
if _SINGERS_SEPARATOR.search(text):
return "、".join(sorted(_SINGERS_SEPARATOR.split(text)))
return text
async def search_music(
*,
name: str,
singer: str | None = None,
source: str | None = None,
only: str | None = None,
limit: int = 25,
) -> list[dict[str, Any]]:
"""多平台聚合搜索。
name 歌名;singer 歌手;source 指定平台时聚合结果跳过该平台;
only 只搜该平台(与 source 互斥,优先生效);limit 单平台结果条数。
"""
import asyncio
music_name = _trim_str(name)
tasks = []
for item in SUPPORTED_SOURCES:
module = item["module"]
if getattr(module, "music_search", None) is None:
continue
# 有声平台(no_aggregate)仅在显式 only 指定时参与搜索
if item.get("no_aggregate") and item["id"] != only:
continue
if only:
if item["id"] != only:
continue
elif item["id"] == source:
continue
keyword = f"{music_name} {singer or ''}".strip()
tasks.append(_search_one(module, keyword, limit))
results = await asyncio.gather(*tasks)
return [item for item in results if item]
async def _search_one(module: Any, keyword: str, limit: int) -> dict[str, Any] | None:
try:
return await module.music_search.search(keyword, 1, limit)
except Exception: # noqa: BLE001
return None
async def find_music(
*,
name: str,
singer: str | None = None,
album_name: str | None = None,
interval: str | None = None,
source: str | None = None,
only: str | None = None,
) -> list[dict[str, Any]]:
"""跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)。"""
lists = await search_music(name=name, singer=singer, source=source, only=only, limit=25)
f_music_name = _filter_str(name).lower()
f_singer = _filter_str(_sort_single(singer)).lower()
f_album_name = _filter_str(album_name).lower()
f_interval = _get_interval_seconds(interval)
def is_equals_interval(value: int) -> bool:
return abs((f_interval or value) - (value or f_interval)) < 5
candidates: list[dict[str, Any]] = []
for result in lists:
for item in result.get("list") or []:
item["name"] = _trim_str(item.get("name"))
item["singer"] = _trim_str(item.get("singer"))
item["_f_singer"] = _filter_str(_sort_single(item.get("singer")).lower())
item["_f_music_name"] = _filter_str(str(item.get("name") or "").lower())
item["_f_album_name"] = _filter_str(str(item.get("albumName") or "").lower())
item["_f_interval"] = _get_interval_seconds(item.get("interval"))
if not is_equals_interval(item["_f_interval"]):
item["name"] = None
continue
candidates.append(item)
matched_order: list[dict[str, Any]] = []
def take(predicate) -> None:
nonlocal candidates
hit = [item for item in candidates if predicate(item)]
rest = [item for item in candidates if not predicate(item)]
matched_order.extend(hit)
candidates = rest
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name and i.get("interval") == interval)
take(
lambda i: (
i["_f_music_name"] == f_music_name and i["_f_singer"] == f_singer and i["_f_album_name"] == f_album_name
)
)
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name)
take(lambda i: i["_f_music_name"] == f_music_name and i.get("interval") == interval)
take(lambda i: i["_f_singer"] == f_singer and i.get("interval") == interval)
take(lambda i: i.get("interval") == interval)
take(lambda i: i["_f_music_name"] == f_music_name)
take(lambda i: i["_f_singer"] == f_singer)
take(lambda i: i["_f_album_name"] == f_album_name)
for item in [*candidates, *matched_order]:
for key in ("_f_singer", "_f_music_name", "_f_album_name", "_f_interval"):
item.pop(key, None)
matched_order.extend(candidates)
return matched_order
+5
View File
@@ -0,0 +1,5 @@
"""取流接口层(对应原 sdk/apis/*.ts)。
每个模块导出 `SUPPORT_QUALITYS: list[str]` 与 `async def get_music_url(song_info, quality, retry_num=0)`,
返回 `{"type": <实际命中音质>, "url": <播放地址>}`。降级链由各平台自行实现。
"""
+78
View File
@@ -0,0 +1,78 @@
"""Apple Music 取流(对应 sdk/apis/apple.ts,上游 apple.go GetDownloadURL)。
⚠️ Apple 只提供 **preview 试听片段**(audio-ssl.itunes.apple.com,AAC/m4a,约 30-90 秒),
完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密,
本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,不假装是无损完整曲。
歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用)。
统一返回:``{type:'preview', url, ext:'m4a', preview:true, songDuration, isrc}``
(songDuration 是歌曲完整时长,试听片段本身约 30-90 秒);
type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐
/sources 与 /url?quality 枚举,返回的实际档位以 type='preview' 为准。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
SUPPORT_QUALITYS: list[str] = ["128k"]
def _is_audio_url(value: Any) -> bool:
text = str(value if value is not None else "")
return text.startswith(("http://", "https://"))
def preview_from_attributes(attr: dict[str, Any]) -> dict[str, Any]:
"""详情 → preview 结果(内部/测试用)。"""
previews = as_list(attr.get("previews"))
url = str(as_dict(previews[0]).get("url") or "") if previews else ""
if not url:
raise RuntimeError("apple music: no preview URL available (完整曲目受 DRM 保护,SDK 仅提供 30-90 秒试听片段)")
try:
duration_ms = float(attr.get("durationInMillis") or 0)
except (TypeError, ValueError):
duration_ms = 0.0
return {
"type": "preview",
"url": url,
"ext": "m4a",
"preview": True,
# 注意:这是歌曲完整时长(catalog durationInMillis),不是试听片段长度
"songDuration": math.floor(duration_ms / 1000 + 0.5),
"isrc": attr.get("isrc") or "",
}
async def get_preview_url(song_id: str) -> dict[str, Any]:
"""按 song id 拉取 preview(上游 GetDownloadURL → Parse)。"""
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
if not data:
raise RuntimeError(f"apple song not found: {song_id}")
return preview_from_attributes(as_dict(as_dict(data[0]).get("attributes")))
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流:preview 直链优先复用,否则按 song id 拉取详情。"""
del quality, retry_num # 只有 preview 一档,请求档位不影响结果
# 上游 GetDownloadURL:Song.URL 已有直链时直接复用(搜索结果里的 previewUrl / 链接解析结果)
if _is_audio_url(song_info.get("previewUrl")):
return {"type": "preview", "url": str(song_info["previewUrl"]), "ext": "m4a", "preview": True}
if _is_audio_url(song_info.get("url")):
return {"type": "preview", "url": str(song_info["url"]), "ext": "m4a", "preview": True}
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效(需要数字 song id 或 Apple Music 歌曲链接)")
return await get_preview_url(song_id)
+84
View File
@@ -0,0 +1,84 @@
"""千千音乐 取流(对应 sdk/apis/bd.ts)。
通道:`/v1/song/tracklink`(签名 GET)。档位:3000=flac / 320 / 128 / 64(aac)。
实测(2026-10 匿名):
* 免费曲目(搜索 isVip=0)各档位均返回明文直链 `data.path`(audio0x.dmhmusic.com,CDN 签名 URL);
* VIP/版权曲目返回 200 / errno 22000 但 `data` 无 path、无 trail_audio_info,
需登录态会员 cookie 才可能有直链 —— 这类曲目沿降级链走到底后如实报错,不造假数据。
**签名串与实际 query 必须同源**:`sign_params` 一次产出签名与参数,query 由
`URLSearchParams` 等价编码拼好后整串交给 `http_fetch`,不会再被二次序列化。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bd.util import api_ok, error_text, first_non_empty, num_or_zero, track_link
# 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
QUALITY_RATES: dict[str, list[str]] = {
"64k": ["64"],
"128k": ["128", "64"],
"320k": ["320", "128", "64"],
"flac": ["3000", "320", "128", "64"],
"flac24bit": ["3000", "320", "128", "64"],
}
DEFAULT_RATES = ["3000", "320", "128", "64"]
# 千千没有 flac24bit 对外档位(上游只到 3000 = flac)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac"]
def type_of_hit(request_rate: str, data: Any) -> str:
"""命中档位判定:以响应 format/rate/bits 为准(可能低于请求档位)。"""
source = data if isinstance(data, dict) else {}
fmt = str(source.get("format") or "").lower()
rate = num_or_zero(source.get("rate"))
bits = num_or_zero(source.get("bits"))
if fmt == "flac" or rate >= 1000:
return "flac24bit" if bits == 24 else "flac"
if rate >= 320 or request_rate == "320":
return "320k"
if rate >= 128 or request_rate == "128":
return "128k"
return "64k"
async def get_download_url(tsid: str, type: str | None = None) -> dict[str, Any]:
"""逐档请求 tracklink,取首个带 path 的档位。"""
id = str(tsid if tsid is not None else "").strip()
if not id:
raise RuntimeError("千千歌曲 id(TSID)为空")
rates = QUALITY_RATES.get(str(type if type is not None else ""), DEFAULT_RATES)
last_err: Exception | None = None
for rate in rates:
try:
body = await track_link(id, rate)
if not api_ok(body):
last_err = RuntimeError(error_text(body, "千千取流失败"))
continue
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
trail = data.get("trail_audio_info")
url = first_non_empty(data.get("path"), trail.get("path") if isinstance(trail, dict) else None)
if url:
return {"type": type_of_hit(rate, data), "url": url}
last_err = RuntimeError("千千该歌曲无可用直链(VIP/版权曲目需登录态,免费曲目可直接取流)")
except Exception as err: # noqa: BLE001 - 单档网络/上游失败沿降级链继续,与原实现一致
last_err = err
if last_err is not None:
raise last_err
raise RuntimeError("千千未找到可用音频直链")
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
# 上游 GetDownloadURL:Song.URL 已有直链时直接复用(Parse 场景)
if song_info.get("url"):
return {"type": quality or "128k", "url": str(song_info["url"])}
tsid = first_non_empty(song_info.get("songmid"), song_info.get("TSID"))
if not tsid:
raise RuntimeError("千千歌曲 id(TSID)为空")
return await get_download_url(tsid, quality)
+257
View File
@@ -0,0 +1,257 @@
"""Bilibili 取流(对应 sdk/apis/bili.ts,上游 bilibili/bilibili.go 的 fetchAudioURL + account.go 的 IsVipAccount)。
接口: x/player/playurl?fnval=&qn=127&bvid=&cid=
* fnval=80 标准 DASH(匿名可用)
* fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;匿名请求该参数也不报错,只是没有 flac 节点)
档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
"""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urljoin, urlsplit
from app.sdk.common.request import http_fetch, http_probe
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
bili_headers,
encode_uri_component,
fetch_view,
get_cookie,
parse_song_id,
)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
QUALITY_LABEL: dict[int, str] = {
30216: "64k",
30232: "132k",
30280: "192k",
30250: "flac",
30251: "flac24bit",
}
#: CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
PROBE_HOP_TIMEOUT = 8.0
PROBE_TOTAL_BUDGET = 20.0
PROBE_MAX_CANDIDATES = 3
_vip_cache: dict[str, Any] = {"value": False, "at": 0.0}
def reset_vip_cache() -> None:
"""登录态清空/更新后调用,丢弃大会员判断缓存。"""
_vip_cache["value"] = False
_vip_cache["at"] = 0.0
async def is_vip_account(cookie: str) -> bool:
"""上游 IsVipAccount:x/web-interface/nav 的 isLogin + vipStatus + vipType(进程内缓存 5 分钟)。"""
if not cookie:
return False
if _vip_cache["at"] and time.time() - _vip_cache["at"] < 5 * 60:
return bool(_vip_cache["value"])
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav", headers=bili_headers(cookie), timeout=15_000
)
body = resp.body if isinstance(resp.body, dict) else {}
data = as_dict(body.get("data"))
vip = (
body.get("code") == 0
and bool(data.get("isLogin"))
and int(data.get("vipStatus") or 0) == 1
and int(data.get("vipType") or 0) > 0
)
_vip_cache["value"] = vip
_vip_cache["at"] = time.time()
return vip
except Exception: # noqa: BLE001 - 判断失败按非会员处理
return False
def host_rank(url: str) -> int:
"""CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / *.akamaized.net,多地节点)
1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方)"""
try:
host = (urlsplit(str(url or "")).hostname or "").lower()
except ValueError:
return 2
if not host:
return 2
if host.endswith(".akamaized.net"):
return 0
if host.endswith(".bilivideo.com"):
return 0 if host.startswith("upos-") else 1
return 2
def node_urls(node: Any) -> list[str]:
"""节点地址收集:baseUrl + backupUrl(去重前的原始序)。"""
if not isinstance(node, dict):
return []
backups: list[Any] = []
if isinstance(node.get("backupUrl"), list):
backups.extend(node["backupUrl"])
if isinstance(node.get("backup_url"), list):
backups.extend(node["backup_url"])
urls = [node.get("baseUrl"), *backups]
return [str(url) for url in urls if url]
def rank_candidates(urls: list[str]) -> list[str]:
"""候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先)。"""
seen: set[str] = set()
uniq: list[str] = []
for url in urls:
text = str(url or "")
if not text or text in seen:
continue
seen.add(text)
uniq.append(text)
ranked = sorted(enumerate(uniq), key=lambda pair: (host_rank(pair[1]), pair[0]))
return [url for _, url in ranked]
def _pick_audio(data: Any, quality: str) -> dict[str, Any] | None:
data = as_dict(data)
dash = as_dict(data.get("dash"))
flac_info = as_dict(dash.get("flac"))
flac = as_dict(flac_info.get("audio"))
hires = flac if int(flac.get("id") or 0) == 30251 and flac.get("baseUrl") else None
dolby_list = as_list(as_dict(dash.get("dolby")).get("audio"))
dolby = None
for item in dolby_list:
if isinstance(item, dict) and int(item.get("id") or 0) == 30250 and item.get("baseUrl"):
dolby = item
break
audio_list = as_list(dash.get("audio"))
standards = sorted(
[item for item in audio_list if isinstance(item, dict) and item.get("baseUrl")],
key=lambda item: -int(item.get("id") or 0),
)
node: dict[str, Any] | None = None
label = ""
if quality == "64k":
low = next((item for item in standards if int(item.get("id") or 0) == 30216), None)
if low:
node = low
label = QUALITY_LABEL[30216]
# 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if node is None and hires:
node = hires
label = QUALITY_LABEL[30251]
if node is None and dolby:
node = dolby
label = QUALITY_LABEL[30250]
if node is None and standards:
node = standards[0]
label = QUALITY_LABEL.get(int(node.get("id") or 0), f"audio-{int(node.get('id') or 0)}")
if node is not None:
return {"id": int(node.get("id") or 0), "label": label, "urls": rank_candidates(node_urls(node))}
durl = as_list(data.get("durl"))
durl_node = durl[0] if durl and isinstance(durl[0], dict) else None
if durl_node and durl_node.get("url"):
return {"id": 0, "label": "durl", "urls": rank_candidates(node_urls(durl_node))}
return None
async def get_audio_url(bvid: str, cid: str | int, quality: str = "128k") -> dict[str, Any]:
"""播放地址 API(上游 fetchAudioURL 的等价实现,返回 ``{"type", "urls"}``;urls 已分级排序,[0] 为最优)。"""
cookie = await get_cookie()
need_flac = quality in ("flac", "flac24bit")
vip = await is_vip_account(cookie)
fnval = 4048 if (need_flac or vip) else 80
url = (
f"https://api.bilibili.com/x/player/playurl?fnval={fnval}&qn=127"
f"&bvid={encode_uri_component(bvid)}&cid={encode_uri_component(cid)}"
)
resp = await http_fetch(url, headers=bili_headers(cookie), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站取流失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站取流失败({body.get('code')}{f': {message}' if message else ''})")
picked = _pick_audio(body.get("data"), quality)
if not picked or not picked["urls"]:
raise RuntimeError("B站未返回可用音频流")
return {"type": picked["label"], "urls": picked["urls"]}
async def _resolve_url(url: str, deadline: float) -> str:
"""探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开。
HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。命中 2xx 返回该地址;
4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
"""
current = str(url)
for _hop in range(3):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise RuntimeError("CDN 探测超时(总预算耗尽)")
timeout_ms = int(min(PROBE_HOP_TIMEOUT, remaining) * 1000)
status, location = await http_probe(
current, headers={**bili_headers(), "Range": "bytes=0-0"}, timeout_ms=timeout_ms
)
if 300 <= status < 400 and location:
try:
current = urljoin(current, location)
continue
except ValueError:
return current
if 200 <= status < 300:
return current
if status >= 400:
raise RuntimeError(f"CDN 返回 HTTP {status}")
return current
return current
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流接口:songmid 约定 ``<bvid>|<cid>``;仅给 bvid 时回源 view 取首P cid。
CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
"""
del retry_num # 契约参数,B 站按候选链自行兜底
ids = parse_song_id(song_info.get("songmid") or song_info.get("id"))
bvid = ids["bvid"]
if not bvid:
raise RuntimeError("bili songmid 需为 <bvid>|<cid> 格式")
real_cid = ids["cid"]
if not real_cid:
view = await fetch_view(bvid)
pages = as_list(view.get("pages"))
first = pages[0] if pages and isinstance(pages[0], dict) else {}
real_cid = str(first.get("cid") if first.get("cid") is not None else "")
if not real_cid:
raise RuntimeError("B站未找到视频 cid(可能已失效)")
info = await get_audio_url(bvid, real_cid, quality)
deadline = time.monotonic() + PROBE_TOTAL_BUDGET
for candidate in info["urls"][:PROBE_MAX_CANDIDATES]:
try:
final_url = await _resolve_url(candidate, deadline)
return {"type": info["type"], "url": final_url}
except Exception: # noqa: BLE001 - 换下一候选
if time.monotonic() >= deadline:
break
# 探测全败(或预算耗尽):回退分级排序后的最优候选
return {"type": info["type"], "url": info["urls"][0]}
+43
View File
@@ -0,0 +1,43 @@
"""5sing 取流(对应 sdk/apis/5sing.ts,上游 fivesing/download.go 的 GetDownloadURL)。
链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=<id>&songtype=<yc|fc|bz>
→ { code: 1000, data: { squrl/squrl_backup, hqurl/hqurl_backup, lqurl/lqurl_backup, ... } }
直链为 CDN 明文地址(wsaudiobssdlbig.kugou.com,https,无需 Referer/防盗链签名;实测 Range 206 可播)。
档位:上游固定 sq(最高)→ hq → lq,返回首个非空(含 _backup);只有显式请求 64k(或 lq)时
才倒序优先 lq,返回实际命中档位。songmid 约定 ``<songId>|<songType>``;
Parse 出来的歌曲带 url 时直接复用(上游 GetDownloadURL: if s.URL != "" return)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_audio_link
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
#: 上游只有 sq/hq/lq 三档,实际命中档位以返回 type 为准(sq 常缺,多命中 hq)
SUPPORT_QUALITYS: list[str] = ["sq", "hq", "lq"]
async def get_download_url(song_info: dict[str, Any], quality: str | None = None) -> dict[str, str]:
"""上游 GetDownloadURL 的等价实现。"""
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
# 上游逻辑:Song.URL 已带直链(Parse 场景)直接复用
if song_info.get("url"):
return {"type": str(song_info.get("type") or quality or "sq"), "url": str(song_info["url"])}
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"] or not ids["songType"]:
raise RuntimeError('invalid id structure: 5sing songmid 需为 "<songId>|<songType>"(songType 为 yc/fc/bz)')
prefer_low = quality in ("64k", "lq")
return await fetch_audio_link(ids["songID"], ids["songType"], prefer_low)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""统一取流:返回 ``{type: 'sq'|'hq'|'lq', url}``(type 为实际命中档位,可能低于请求档位)。"""
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
hit = await get_download_url(song_info, quality)
return {"type": hit["type"], "url": hit["url"]}
+65
View File
@@ -0,0 +1,65 @@
"""JOOX 取流(对应 sdk/apis/joox.ts)。
链路:GET cache.api.joox.com/openjoox2/v1/track/<songid>?...&secret=<md5>
→ ``play_url_list`` 首个 http 地址(无则 ``refrain_url``)→ 带 vkey/hdnts 的 CDN 直链。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 实测),
故取流改用官网当前使用的 openjoox2 通道(签名细节见 ``platforms/joox/music_info.py``)。
地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0;登录 cookie 的
wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
实测(游客态):免费曲目拿到 128k MP3 完整流;VIP 曲目只给 preview/refrain 片段
(``node_is_preview: true``、``error_code: 9009002``),与上游行为一致。
音质档位:游客通道实测只有 128k MP3。JOOX 的 has_hq/has_hifi 标志不代表游客可取,
登录态能否拿到更高档位未验证,故 ``SUPPORT_QUALITYS`` 保守地只声明 128k,不虚报。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, joox_headers, normalize_joox_id
SUPPORT_QUALITYS: list[str] = ["128k"]
#: 本平台实际只能返回这一档;返回值里的 type 用它而不是请求的 quality(与 TS 返回 type: '128k' 一致)
_ACTUAL_QUALITY = "128k"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流:openjoox2 详情 → play_url_list / refrain_url。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(song_id, full_song=True)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 取流失败:{msg or f'HTTP {res.status_code}'}")
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = body.get("play_url_list")
play_url = ""
for item in urls if isinstance(urls, list) else []:
candidate = clean(item)
if candidate.startswith("http"):
play_url = candidate
break
if not play_url:
refrain = clean(body.get("refrain_url"))
if refrain.startswith("http"):
play_url = refrain
if not play_url:
raise RuntimeError("无可用播放地址(VIP/版权限制或地区不可用)")
return {"type": _ACTUAL_QUALITY, "url": play_url}
+122
View File
@@ -0,0 +1,122 @@
"""酷狗音乐 取流(对应 sdk/apis/kg.ts)。
通道:`https://gateway.kugou.com/v5/url`(x-router: trackercdn.kugou.com,android 签名)。
登录后自动带 token/userid(见 /login/kg);匿名仅能取部分免费资源,VIP/受限资源返回 status:2。
音质映射:128k→128、320k→320、flac→flac、flac24bit→high。
成功响应播放地址在顶层 url: string[] 与 backupUrl: string[];旧 data:[{url}] 仅作兼容兜底。
album_audio_id 传 NaN 会被网关拒答,必须归零。
注意:TS 原实现在这里 `getDevice()` + `androidSign`,但那两个函数在 `sdk/auth/kg.ts`,
Python 侧 `app/sdk/auth/kg.py` 仍是 stub(不在本次写入范围),因此设备指纹与签名
改为 `app/sdk/platforms/kg/util.py` 的实现 —— 算法逐字一致,指纹同样落在
`auth.json` 的 `_kg_device`,与登录态共用。
"""
from __future__ import annotations
import hashlib
import math
import time
from typing import Any
from app.sdk.auth_store import get_kg_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.util import android_sign, encode_uri_component, get_device
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
SALT_KEY = "57ae12eb6890223e355ccfcb74edf70d"
APPID = 1005
CLIENTVER = 11430
QUALITY_MAP = {"128k": "128", "320k": "320", "flac": "flac", "flac24bit": "high"}
URL_API = "https://gateway.kugou.com/v5/url"
def _num(value: Any) -> int:
"""JS 的 Number() 语义:不可解析或非有限值一律归零(网关拒答 NaN)。"""
try:
number = float(value)
except (TypeError, ValueError):
return 0
return int(number) if math.isfinite(number) else 0
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
types = song_info.get("_types") or {}
quality_info = types.get(quality) if isinstance(types, dict) else None
hash_value = str((quality_info or {}).get("hash") or song_info.get("hash") or "").lower()
if not hash_value:
raise RuntimeError("缺少 hash")
dev = get_device()
auth = get_kg_auth()
userid = _num((auth or {}).get("userid") or 0)
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"album_id": _num(song_info.get("albumId")),
"area_code": 1,
"hash": hash_value,
"ssa_flag": "is_fromtrack",
"version": CLIENTVER,
"page_id": 151369488,
"quality": QUALITY_MAP.get(quality, "128"),
"album_audio_id": _num(song_info.get("songmid")),
"behavior": "play",
"pid": 2,
"cmd": 26,
"pidversion": 3001,
"IsFreePart": 0,
"ppage_id": "463467626,350369493,788954147",
"cdnBackup": 1,
"module": "",
}
if auth:
params["token"] = auth["token"]
params["userid"] = userid
params["key"] = hashlib.md5(f"{hash_value}{SALT_KEY}{APPID}{dev['mid']}{userid}".encode()).hexdigest()
params["signature"] = android_sign(params)
qs = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"{URL_API}?{qs}",
method="get",
headers={
"dfid": dev["dfid"],
"mid": dev["mid"],
"clienttime": str(params["clienttime"]),
"kg-rc": "1",
"kg-thash": "5d816a0",
"kg-rec": "1",
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
"x-router": "trackercdn.kugou.com",
"User-Agent": "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi",
},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
raise RuntimeError(str(body.get("error") or "获取失败(该资源需要酷狗登录)"))
candidates: list[Any] = []
if isinstance(body.get("url"), list):
candidates.extend(body["url"])
if isinstance(body.get("backupUrl"), list):
candidates.extend(body["backupUrl"])
url = next((str(item) for item in candidates if item), "")
if not url and isinstance(body.get("data"), list):
for entry in body["data"]:
if isinstance(entry, dict) and (entry.get("url") or entry.get("fallback")):
url = str(entry.get("url") or entry.get("fallback"))
break
if not url:
raise RuntimeError("获取失败(网关未返回播放地址)")
return {"type": quality, "url": url}
+220
View File
@@ -0,0 +1,220 @@
"""酷我音乐 取流(对应 sdk/apis/kw.ts)。
双通道,按优先级:
1) mobi.s convert_url_with_sign(主通道,匿名可用)
`source=kwplayer_ar_8.5.5.0_apk_keluze.apk` + `user=0` 即可匿名拿到真音频,
但请求档位缺失时会**静默换档**(实测 rid=440613 请求 4000kflac 回的是
bitrate=20501 / format=mflac 的加密流,不可播),因此必须严格校验
「返回 bitrate 精确等于请求档位」且「format 在明文白名单内」,
任一不满足视为未命中、沿降级链换档重试。
2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
签名所需 cookie(`Hm_Iuvt_*`,名字与值均随机下发)必须现场访问首页动态获取;
写死 token 一律返回 `The request is illegal!`(已实测确认)。
匿名该接口恒回 128k MP3,是 mobi 通道网络故障时的兜底。
CDN 返回的 URL query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`),
非标准分隔符播放器不认,统一还原成 `=` 再对外返回。
"""
from __future__ import annotations
import asyncio
import re
import uuid
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kw.util import (
HOME_URL,
MOBI_UA,
UA,
fetch_home_set_cookie,
random_digits,
)
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
# 音质 → mobi.s 请求档位(bitrate 是响应必须精确命中的数值)
QUALITY_TIERS: dict[str, dict[str, Any]] = {
"128k": {"br": "128kmp3", "bitrate": 128},
"320k": {"br": "320kmp3", "bitrate": 320},
"flac": {"br": "2000kflac", "bitrate": 2000},
"flac24bit": {"br": "4000kflac", "bitrate": 4000},
}
# 档位缺失时的降级链(请求档位 → 逐级尝试)
FALLBACK_CHAIN: dict[str, list[str]] = {
"flac24bit": ["flac", "320k", "128k"],
"flac": ["320k", "128k"],
"320k": ["128k"],
"128k": [],
}
# 明文音频格式白名单;mgg/mflac/mmp4/zp 是加密格式,拿到也不能播
PLAIN_FORMATS = frozenset({"mp3", "flac", "ogg", "aac", "m4a"})
MOBI_API = "http://mobi.kuwo.cn/mobi.s"
PLAY_URL_API = "https://www.kuwo.cn/api/v1/www/music/playUrl"
_TOKEN_NAME_RE = re.compile(r"^Hm_Iuvt_\w+$")
_ILLEGAL_RE = re.compile(r"illegal", re.IGNORECASE)
def normalize_url(url: str) -> str:
return url.replace("$", "=")
async def get_url_by_mobi(songmid: str, tier: dict[str, Any]) -> str | None:
"""mobi.s 取流:未命中(无 url / bitrate 不符 / 加密格式 / 网络异常)返回 None,由调用方换档。"""
url = (
f"{MOBI_API}?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk"
f"&type=convert_url_with_sign&br={tier['br']}&rid={songmid}"
)
try:
resp = await http_fetch(
url,
method="get",
headers={"User-Agent": MOBI_UA, "Accept-Language": "zh-CN,zh;q=0.9"},
timeout=15_000,
)
except Exception: # noqa: BLE001 — 网络失败视为本档未命中,继续降级
return None
body = resp.body if isinstance(resp.body, dict) else {}
data = body.get("data") or {}
if resp.status_code != 200 or not isinstance(data, dict) or not data.get("url"):
return None
raw_bitrate = data.get("bitrate")
if not isinstance(raw_bitrate, (int, float, str)):
return None
try:
bitrate = int(float(raw_bitrate))
except (TypeError, ValueError):
return None
if bitrate != tier["bitrate"] or str(data.get("format")) not in PLAIN_FORMATS:
return None
return normalize_url(str(data["url"]))
def kw_secret(message: str, key: str) -> str:
"""酷我 web 端 Secret 头哈希:key 为 cookie 名,message 为 cookie 值。
逐字移植自 web 端 secret.js:先把 key 的每个字符码拼成数字串,
取 5 个固定位置的数字组成乘子,再用随机种子一起做 LCG 式的异或掩码。
"""
digits = "".join(str(ord(ch)) for ch in key)
step = len(digits) // 5
multiplier = int(digits[step] + digits[2 * step] + digits[3 * step] + digits[4 * step] + digits[5 * step])
addend = -(-len(key) // 2) # Math.ceil(e.length / 2)
modulus = 2**31 - 1
if multiplier < 2:
raise RuntimeError("酷我 Secret 哈希失败")
seed = random_digits() % 10**8
digits += str(seed)
while len(digits) > 10:
digits = str(int(digits[:10]) + int(digits[10:]))
state = (multiplier * int(digits) + addend) % modulus
out: list[str] = []
for ch in message:
mask = int(state / modulus * 255) # 对应 Math.floor(n / c * 255)
value = ord(ch) ^ mask
out.append(f"{value:02x}" if value < 16 else format(value, "x"))
state = (multiplier * state + addend) % modulus
return "".join(out) + f"{seed:08x}"
class KwToken:
__slots__ = ("name", "secret", "value")
def __init__(self, name: str, value: str) -> None:
self.name = name
self.value = value
self.secret = kw_secret(value, name)
_state: dict[str, Any] = {"token": None, "lock": None}
def _lock() -> asyncio.Lock:
lock = _state["lock"]
if lock is None:
lock = asyncio.Lock()
_state["lock"] = lock
return lock
async def get_token(retry_num: int = 0) -> KwToken:
"""访问首页解析动态下发的 `Hm_Iuvt_*` cookie,生成 Secret。会话级缓存。"""
cached: KwToken | None = _state["token"]
if cached is not None:
return cached
async with _lock():
cached = _state["token"]
if cached is not None:
return cached
try:
cookies = await fetch_home_set_cookie()
except Exception as err:
if retry_num >= 2:
raise RuntimeError(f"获取酷我token失败: {err}") from err
return await get_token(retry_num + 1)
found: list[tuple[str, str]] = []
for raw in cookies:
name, _, rest = raw.partition("=")
name = name.strip()
value = rest.split(";")[0] if rest else ""
if _TOKEN_NAME_RE.match(name) and value:
found.append((name, value))
if not found:
if retry_num >= 2:
raise RuntimeError("获取酷我token失败")
return await get_token(retry_num + 1)
token = KwToken(found[0][0], found[0][1])
_state["token"] = token
return token
async def get_url_by_playurl(songmid: str, token: KwToken, retry_num: int = 0) -> dict[str, Any]:
"""官方 playUrl 接口(需 Secret 签名)。签名/token 失效时刷新后重试一次。"""
url = f"{PLAY_URL_API}?mid={songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId={uuid.uuid4()}"
resp = await http_fetch(
url,
method="get",
headers={
"Cookie": f"{token.name}={token.value}",
"Secret": token.secret,
"Referer": HOME_URL,
"User-Agent": UA,
},
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
msg = str(body.get("msg") or body.get("message") or "")
data = body.get("data") or {}
if resp.status_code != 200 or not body.get("success") or not isinstance(data, dict) or not data.get("url"):
if retry_num == 0 and _ILLEGAL_RE.search(msg):
_state["token"] = None
fresh = await get_token()
return await get_url_by_playurl(songmid, fresh, retry_num + 1)
raise RuntimeError(msg or "获取失败")
# 服务器无视 br 参数,匿名实际只回 128k,如实上报
return {"type": "128k", "url": normalize_url(str(data["url"]))}
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "").strip()
if not songmid:
raise RuntimeError("缺少 songmid")
start = quality if quality in QUALITY_TIERS else "128k"
# 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
for tier_type in [start, *FALLBACK_CHAIN[start]]:
hit = await get_url_by_mobi(songmid, QUALITY_TIERS[tier_type])
if hit:
return {"type": tier_type, "url": hit}
token = await get_token()
return await get_url_by_playurl(songmid, token)
+57
View File
@@ -0,0 +1,57 @@
"""懒人听书 URL 获取(对应 sdk/apis/lr.ts,web 通道 getPlayPath,需登录)。
接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1&sections=[<section>]&type=0
实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
成功响应 list[0].path 为音频直链。
songmid 为复合 id ``<bookId>:<section>``(/album/lr 章节列表产出);纯 bookId 视为第一章。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.auth.lr import get_lr_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
UA_MI = (
"Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0"
)
PLAY_URL = "https://m.lrts.me/ajax/getPlayPath"
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,无重试语义
songmid = str(song_info.get("songmid") or "")
parts = songmid.split(":")
book_id = parts[0] if parts else ""
section = parts[1] if len(parts) > 1 and parts[1] else "1"
if not book_id or not re.fullmatch(r"\d+", book_id):
raise RuntimeError("懒人 id 需为 <bookId>[:<section>](见 /album/lr 章节列表)")
cookie = get_lr_cookie()
if not cookie:
raise RuntimeError("懒人听书取流需要登录(POST /login/lr 绑定账号密码)")
resp = await http_fetch(
f"{PLAY_URL}?entityId={book_id}&entityType=3&opType=1&sections=[{section}]&type=0",
headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/", "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200:
raise RuntimeError(f"取流失败(HTTP {resp.status_code})")
items = as_list(body.get("list"))
path = as_dict(items[0]).get("path") if items else None
if not path:
if body.get("status") == 2:
raise RuntimeError(str(body.get("msg") or "收费章节未购买(需账号已购/会员)"))
if "登录" in str(body.get("msg") or ""):
raise RuntimeError("登录态已失效,请重新 POST /login/lr")
raise RuntimeError(str(body.get("msg") or "取流失败"))
return {"type": "128k" if quality == "128k" else "64k", "url": str(path)}
+78
View File
@@ -0,0 +1,78 @@
"""猫耳FM URL 获取(对应 sdk/apis/me.ts,App API 通道)。
链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带。
songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出);
"d:<drama_id>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.me.music_info import get_sound_info
#: 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
QUALITY_FIELDS: dict[str, list[str]] = {
"128k": ["soundurl_128", "soundurl", "soundurl_64", "soundurl_32"],
"64k": ["soundurl_64", "soundurl", "soundurl_128", "soundurl_32"],
}
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def pick_url(info: dict[str, Any], quality: str) -> dict[str, str]:
fields = QUALITY_FIELDS.get(quality, QUALITY_FIELDS["128k"])
url = ""
for field in fields:
value = info.get(field)
if value and str(value).startswith("http"):
url = str(value)
break
if not url:
raise RuntimeError("无可用音频资源(付费内容需 POST /login/me 绑定 token)")
hit_128 = "-128k" in url or quality == "128k"
return {"type": "128k" if hit_128 else "64k", "url": url}
async def _resolve_drama_first_free(drama_id: str) -> str:
"""剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[])。"""
resp = await http_fetch(
f"https://www.missevan.com/dramaapi/getdrama?drama_id={drama_id}",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
),
"Accept": "application/json",
"Referer": f"https://www.missevan.com/mdrama/{drama_id}",
},
timeout=15_000,
)
info = as_dict(resp.body).get("info") if resp.status_code == 200 else None
episodes = as_list(as_dict(as_dict(info).get("episodes")).get("episode"))
if not episodes:
raise RuntimeError("剧集列表为空或已下线")
first = next((item for item in episodes if int(as_dict(item).get("pay_type") or 0) == 0), episodes[0])
return str(as_dict(first).get("sound_id"))
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,无重试语义
songmid = str(song_info.get("songmid") or "")
sound_id = songmid
if songmid.startswith("d:"):
if not re.fullmatch(r"\d+", songmid[2:]):
raise RuntimeError("剧 id 无效")
sound_id = await _resolve_drama_first_free(songmid[2:])
elif not re.fullmatch(r"\d+", sound_id):
raise RuntimeError("me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)")
info = await get_sound_info(sound_id)
return pick_url(info, quality)
+390
View File
@@ -0,0 +1,390 @@
"""咪咕音乐 取流(对应 sdk/apis/mg.ts)。
候选链:listen-url 多档位请求 → 按档位优先级产出「内嵌加密 / 明文直链 / 档位路径重写」
三类候选 → 逐条 Range 0-63 探测(校验音频魔数,加密流先解密再校验)→ 返回实际命中的最高档位。
匿名态仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie(接口回「登录后免费试听」)。
加密流:返回 `{"encrypted": True, "fileKey": "..."}`,路由据此改写成 `/stream/mg/<票据>`,
真正的逐字节解密在 `app/http/mg_stream.py`(本模块只负责产出字段与探测阶段的自校验)。
上游对应 music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import quote, unquote, urlencode, urlsplit, urlunsplit
from app.sdk.common.request import http_fetch
from app.sdk.platforms.mg.util import (
ANDROID_CHANNEL,
ANDROID_UA,
ANDROID_VERSION,
TONE_EXT,
TONE_QUALITY,
decrypt_z3d,
first_non_empty,
get_migu_cookie,
is_migu_audio_magic,
migu_format_ext,
normalize_tone,
parse_int,
)
SUPPORT_QUALITYS: list[str] = ["64k", "128k", "320k", "flac", "flac24bit"]
LISTEN_URL = "https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url"
#: 平台档位 → 咪咕 toneFlag(取流请求用)
QUALITY_TONE: dict[str, str] = {
"64k": "LQ",
"128k": "PQ",
"320k": "HQ",
"flac": "SQ",
"flac24bit": "ZQ24",
"wav": "ZQ32",
}
#: 可重写的 tone 路径(上游 miguTonePaths)
TONE_PATHS: dict[str, str] = {
"LQ": "全曲试听/Mp3_64_22_16",
"PQ": "标清高清/MP3_128_16_Stero",
"HQ": "标清高清/MP3_320_16_Stero",
"SQ": "歌曲下载/flac",
"ZQ": "歌曲下载/flac_24bit",
"ZQ24": "歌曲下载/flac_24bit",
"ZQ32": "歌曲下载/wav_32bit",
}
TONE_PREFERENCE: list[str] = ["ZQ32", "ZQ24", "ZQ", "SQ", "HQ", "Z3D", "I3D", "3D60", "PQ", "LQ"]
#: 档位路径探测顺序(长的在前,避免 `/flac` 抢匹配 `/flac_24bit`)
_TONE_PATH_ORDER = ("ZQ24", "ZQ", "ZQ32", "SQ", "HQ", "LQ", "PQ")
MAX_PROBES = 10
def migu_song_parts(song_info: dict[str, Any]) -> dict[str, str]:
"""songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c`。"""
extra = song_info.get("extra") or {}
extra = extra if isinstance(extra, dict) else {}
content_id = str(extra.get("content_id") or "").strip()
resource_type = str(extra.get("resource_type") or "").strip()
format_type = str(extra.get("format_type") or "").strip()
if content_id and resource_type and format_type:
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
raw_id = str(song_info.get("songmid") or song_info.get("id") or "")
parts = raw_id.split("|")
if len(parts) == 3:
return {"contentId": parts[0].strip(), "resourceType": parts[1].strip(), "formatType": parts[2].strip()}
if not content_id:
content_id = raw_id.strip()
return {"contentId": content_id, "resourceType": resource_type, "formatType": format_type}
async def _fetch_listen_info(
content_id: str, copyright_id: str, song_id: str, album_id: str, resource_type: str, tone_flag: str
) -> dict[str, Any]:
"""listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误。"""
params: list[tuple[str, str]] = [
("netType", "01"),
("resourceType", first_non_empty(resource_type, "2")),
("contentId", content_id.strip()),
("toneFlag", first_non_empty(tone_flag, "PQ")),
]
if copyright_id.strip():
params.append(("copyrightId", copyright_id.strip()))
if song_id.strip():
params.append(("songId", song_id.strip()))
if album_id.strip():
params.append(("albumId", album_id.strip()))
headers: dict[str, str] = {
"User-Agent": ANDROID_UA,
"channel": ANDROID_CHANNEL,
"version": ANDROID_VERSION,
"Referer": "https://music.migu.cn/",
}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(f"{LISTEN_URL}?{urlencode(params)}", headers=headers, timeout=30_000)
if resp.status_code < 200 or resp.status_code >= 300:
raise RuntimeError(f"migu listen endpoint returned status {resp.status_code}")
result = resp.body if isinstance(resp.body, dict) else {}
if result.get("code") and result["code"] != "000000":
raise RuntimeError(f"migu api error: {result.get('info')} (code {result.get('code')})")
return result
def _normalize_download_url(raw_url: Any) -> str:
url = str(raw_url if raw_url is not None else "").strip()
if not url:
return ""
if url.startswith("//"):
url = f"https:{url}"
prefix = "ftp://218.200.160.122:21/"
if url.startswith(prefix):
url = f"https://freetyst.nf.migu.cn/{url[len(prefix) :]}"
return url
def _direct_download_info(raw_url: str, fmt: str, expected_size: int) -> dict[str, Any] | None:
url = _normalize_download_url(raw_url)
if not url:
return None
ext = ""
try:
path = urlsplit(url).path
ext = (path.split(".")[-1] or "").lower()
if not ext or "/" in ext:
ext = ""
except ValueError:
ext = ""
tone = normalize_tone(fmt)
if not ext:
ext = TONE_EXT.get(tone, "")
if not ext:
ext = "mp3"
if not tone:
# 按扩展名回填档位(上游 formatFromMiguExt)
tone = {"flac": "SQ", "wav": "ZQ32", "m4a": "I3D"}.get(ext, "PQ")
return {"url": url, "format": tone, "ext": ext, "encrypted": False, "fileKey": "", "size": expected_size}
def _embedded_download_info(resp: dict[str, Any], target_tone: str, expected_size: int) -> dict[str, Any] | None:
"""z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse。"""
data = resp.get("data") if isinstance(resp, dict) else None
song_item = (data or {}).get("songItem") if isinstance(data, dict) else None
code = (song_item or {}).get("z3dCode") if isinstance(song_item, dict) else None
if not isinstance(code, dict):
return None
def parse_size(value: Any) -> int:
size = parse_int(value)
return size if size > 0 else 0
if target_tone == "Z3D":
url = _normalize_download_url(first_non_empty(code.get("androidUrl"), code.get("url")))
file_key = str(code.get("androidFileKey") or "").strip()
if not url or not file_key:
return None
ext = first_non_empty(str(code.get("androidFileType") or "").removeprefix("."), "wav")
return {
"url": url,
"format": "Z3D",
"ext": ext,
"encrypted": True,
"fileKey": file_key,
"size": parse_size(code.get("androidSize")) or expected_size,
}
if target_tone == "I3D":
url = _normalize_download_url(first_non_empty(code.get("iosUrl"), code.get("url")))
file_key = str(code.get("iosFileKey") or "").strip()
if not url or not file_key:
return None
ext = first_non_empty(str(code.get("iosFileType") or "").removeprefix("."), "m4a")
return {
"url": url,
"format": "I3D",
"ext": ext,
"encrypted": True,
"fileKey": file_key,
"size": parse_size(code.get("iosSize")) or expected_size,
}
if target_tone == "3D60":
url = _normalize_download_url(first_non_empty(code.get("url"), code.get("h5Url")))
if not url:
return None
ext = first_non_empty(str(code.get("fileType") or "").removeprefix("."), "wav")
return {
"url": url,
"format": "3D60",
"ext": ext,
"encrypted": False,
"fileKey": "",
"size": parse_size(code.get("size")) or expected_size,
}
return None
def _detect_tone_path(decoded_path: str) -> str:
for tone in _TONE_PATH_ORDER:
tone_path = TONE_PATHS.get(tone, "")
if tone_path and tone_path in decoded_path:
return tone_path
return ""
def _rewrite_tone_url(raw_url: str, target_tone: str) -> str | None:
"""把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL)。"""
tone = normalize_tone(target_tone)
target_path = TONE_PATHS.get(tone)
target_ext = TONE_EXT.get(tone)
if not target_path or not target_ext or tone in ("Z3D", "I3D", "3D60"):
return None
try:
parts = urlsplit(_normalize_download_url(raw_url))
except ValueError:
return None
decoded_path = unquote(parts.path)
source_path = _detect_tone_path(decoded_path)
if not source_path:
return None
new_path = decoded_path.replace(source_path, target_path)
if new_path == decoded_path:
return None
dot_index = new_path.rfind(".")
if dot_index > new_path.rfind("/"):
new_path = new_path[:dot_index]
new_path += f".{target_ext}"
# JS 的 `parsed.pathname = ...` 会把中文等非 ASCII 重新百分号编码
return urlunsplit((parts.scheme, parts.netloc, quote(new_path, safe="/"), parts.query, parts.fragment))
def _build_candidates(responses: list[dict[str, Any]], expected_size: int) -> list[dict[str, Any]]:
"""候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选。"""
sources: list[dict[str, str]] = []
seen_sources: set[str] = set()
for resp in responses:
data = resp.get("data") if isinstance(resp, dict) else None
candidate_url = _normalize_download_url((data or {}).get("url") if isinstance(data, dict) else None)
if not candidate_url:
continue
actual_format = normalize_tone((data or {}).get("formatType")) or "PQ"
key = f"{actual_format}\u0000{candidate_url}"
if key in seen_sources:
continue
seen_sources.add(key)
sources.append({"url": candidate_url, "format": actual_format})
candidates: list[dict[str, Any]] = []
seen: set[str] = set()
def add(info: dict[str, Any] | None) -> None:
if not info or not info["url"]:
return
key = f"{info['format']}\u0000{info['url']}"
if key in seen:
return
seen.add(key)
candidates.append(info)
for tone in TONE_PREFERENCE:
for resp in responses:
add(_embedded_download_info(resp, tone, expected_size))
for source in sources:
if tone == source["format"]:
add(_direct_download_info(source["url"], source["format"], expected_size))
continue
rewritten = _rewrite_tone_url(source["url"], tone)
if rewritten:
add(_direct_download_info(rewritten, tone, 0))
return candidates
async def _download_info_valid(info: dict[str, Any]) -> bool:
"""探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积。"""
try:
headers: dict[str, str] = {
"User-Agent": ANDROID_UA,
"Referer": "https://music.migu.cn/",
"Range": "bytes=0-63",
}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(info["url"], headers=headers, timeout=10_000)
if resp.status_code < 200 or resp.status_code >= 300:
return False
prefix = resp.raw[:64]
if not prefix:
return False
if info["encrypted"]:
try:
decrypt_z3d(prefix, info["fileKey"])
except Exception: # noqa: BLE001 - 探测失败即视为该候选不可用
return False
elif not is_migu_audio_magic(prefix):
return False
content_range = resp.header("content-range")
slash = content_range.rfind("/")
if slash >= 0:
size = parse_int(content_range[slash + 1 :])
if size > 0:
info["size"] = size
return True
except Exception: # noqa: BLE001 - 任一候选探测异常都只代表该候选不可用
return False
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口:候选链 + 逐档探测;返回实际命中的最高档位。"""
parts = migu_song_parts(song_info)
content_id = parts["contentId"]
if not content_id:
raise RuntimeError("migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)")
resource_type = parts["resourceType"] or "2"
target_tone = (
normalize_tone(QUALITY_TONE.get(quality, ""))
or normalize_tone(parts["formatType"])
or normalize_tone(song_info.get("ext"))
or "PQ"
)
extra = song_info.get("extra") or {}
extra = extra if isinstance(extra, dict) else {}
copyright_id = str(extra.get("copyright_id") or "")
song_id = str(extra.get("song_id") or "")
album_id = str(extra.get("album_id") or song_info.get("albumId") or "")
request_tones: list[str] = []
for tone in ("ZQ32", target_tone, "PQ"):
if tone not in request_tones:
request_tones.append(tone)
responses: list[dict[str, Any]] = []
fetch_err: Exception | None = None
for tone in request_tones:
try:
responses.append(await _fetch_listen_info(content_id, copyright_id, song_id, album_id, resource_type, tone))
except Exception as err: # noqa: BLE001 - 多档位请求,单档失败不影响其它档
if fetch_err is None:
fetch_err = err
if not responses:
raise fetch_err or RuntimeError("migu listen-url 全部失败")
expected_size = parse_int(song_info.get("size"))
candidates = _build_candidates(responses, expected_size)
for probed, candidate in enumerate(candidates):
if probed >= MAX_PROBES:
break
if await _download_info_valid(candidate):
result: dict[str, Any] = {
"type": TONE_QUALITY.get(candidate["format"], quality),
"url": candidate["url"],
}
if candidate["encrypted"]:
result["encrypted"] = True
result["fileKey"] = candidate["fileKey"]
result["ext"] = candidate["ext"] or migu_format_ext(candidate["format"])
result["size"] = candidate["size"]
return result
# 全部候选不可用:优先回传接口文案(如「登录后免费试听」)
dialog = next(
(
str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
for resp in responses
if str(((resp.get("data") or {}).get("dialogInfo") or {}).get("text") or "").strip()
),
"",
)
if dialog:
raise RuntimeError(f"migu 无可用音频:{dialog}(登录后可解锁更高档位)")
if fetch_err is not None:
raise fetch_err
raise RuntimeError("migu 未返回可访问的音频档位")
+42
View File
@@ -0,0 +1,42 @@
"""蜻蜓FM URL 获取(对应 sdk/apis/qt.ts,点播 audiostream 签名直链)。
链路(官网 main.js 逆向,2026-10-02 实测):
GET https://audio.qtfm.cn/audiostream/redirect/<channel>/<program>?access_token=&device_id=MOBILESITE&qingting_id=&t=<ms>&sign=<hex>
sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外)
匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3)
直播电台另有 /live/<cid>/64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn)
songmid 为复合 id ``<channelId>:<programId>``(/album/qt 列表产出)。
"""
from __future__ import annotations
import hashlib
import hmac
import time
from typing import Any
SIGN_KEY = b"7l8CZ)SgZgM_bkrw"
AUDIO_HOST = "https://audio.qtfm.cn"
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def build_program_url(channel_id: str, program_id: str) -> str:
path = (
f"/audiostream/redirect/{channel_id}/{program_id}"
f"?access_token=&device_id=MOBILESITE&qingting_id=&t={int(time.time() * 1000)}"
)
sign = hmac.new(SIGN_KEY, path.encode(), hashlib.md5).hexdigest()
return f"{AUDIO_HOST}{path}&sign={sign}"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数;直链由签名即时生成,无需请求上游
songmid = str(song_info.get("songmid") or "")
parts = songmid.split(":")
channel_id = parts[0] if parts else ""
program_id = parts[1] if len(parts) > 1 else ""
if not channel_id or not program_id:
raise RuntimeError("蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)")
return {"type": "128k" if quality == "128k" else "64k", "url": build_program_url(channel_id, program_id)}
+145
View File
@@ -0,0 +1,145 @@
"""QQ音乐取流(移植自 sdk/apis/tx.ts)。
接口:musicu.fcg 的 vkey.GetVkeyServer/CgiGetVkey。
取流方案(与原文一致):
* 一次请求带上整条音质候选链的 filename,命中第一个有 purl 的档位即「最高可用音质」;
* 始终附带 `C400{songmid}.m4a` 保底 —— 匿名账户唯一能拿到的 128k AAC;
* 档位前缀:RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC > F000 无损 FLAC >
M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
* VIP 档 filename 用 media_mid(songmid ≠ media_mid 时用媒体 id),保底档用 songmid;
* 登录 cookie(uin + qm_keyst,见 /login/tx)才可能解锁 320k/无损/Hi-Res,
未登录时高档位 purl 为空,只能落到 C400 或 testfile2g 试听地址。
参数映射:路由的 song_info 只有 {"songmid", "albumId", ...},没有 media_mid。
因此先查 songmid → media_mid(musicu 的 pf_song_detail_svr),查不到就退回用 songmid
(与原文 `songInfo.strMediaMid || songInfo.songmid` 同义)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils import MUSICU_URL
# 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
_CANDIDATES: dict[str, list[tuple[str, str, str]]] = {
"128k": [("M500", "mp3", "128k")],
"320k": [("M800", "mp3", "320k"), ("M500", "mp3", "128k")],
"flac": [("F000", "flac", "flac"), ("M800", "mp3", "320k"), ("M500", "mp3", "128k")],
"flac24bit": [
("RS01", "flac", "flac24bit"),
("Q000", "flac", "flac24bit"),
("AI00", "flac", "flac24bit"),
("F000", "flac", "flac"),
("M800", "mp3", "320k"),
("M500", "mp3", "128k"),
],
}
SUPPORT_QUALITYS: list[str] = list(_CANDIDATES)
_UA = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
_mid_cache: dict[str, str] = {}
async def _resolve_media_mid(songmid: str) -> str:
"""songmid → media_mid。失败/缺失时返回空串,由调用方退回 songmid。"""
cached = _mid_cache.get(songmid)
if cached is not None:
return cached
media_mid = ""
try:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"module": "music.pf_song_detail_svr",
"method": "get_song_detail_yqq",
"param": {"song_type": 0, "song_mid": songmid},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
track = ((body.get("req") or {}).get("data") or {}).get("track_info") or {}
media_mid = str((track.get("file") or {}).get("media_mid") or "")
except (RuntimeError, OSError, TimeoutError):
# 查不到不阻塞取流:退回 songmid(无名曲目/接口抖动时的降级行为)
return ""
_mid_cache[songmid] = media_mid
return media_mid
def _build_entries(song_info: dict[str, Any], quality: str, media_mid: str) -> list[dict[str, str]]:
songmid = str(song_info.get("songmid") or "")
entries: list[dict[str, str]] = []
for prefix, ext, hit_type in _CANDIDATES.get(quality) or _CANDIDATES["128k"]:
entries.append({"filename": f"{prefix}{media_mid}.{ext}", "type": hit_type})
# 保底:匿名 128k AAC 用 songmid(不是 media_mid)
entries.append({"filename": f"C400{songmid}.m4a", "type": "128k"})
return entries
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。返回 {"type": 实际命中音质, "url": 播放地址}(type 可能低于请求值)。
失败文案与原文一致:`获取失败`。
"""
if retry_num > 2:
raise RuntimeError("获取失败")
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取失败")
media_mid = str(song_info.get("strMediaMid") or "") or await _resolve_media_mid(songmid) or songmid
entries = _build_entries(song_info, quality, media_mid)
auth = get_tx_auth()
uin = str(auth.get("uin") or "0") if auth else "0"
headers = {"User-Agent": _UA, "Referer": "https://y.qq.com/"}
if auth and auth.get("cookie"):
headers["cookie"] = str(auth["cookie"])
data = {
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {
"module": "vkey.GetVkeyServer",
"method": "CgiGetVkey",
"param": {
"guid": "7342827269",
"songmid": [songmid],
"filename": [entry["filename"] for entry in entries],
"songtype": [0],
"uin": uin,
"loginflag": 1,
"platform": "20",
"needNewCode": 1,
},
},
}
resp = await http_fetch(MUSICU_URL, method="post", headers=headers, body=data, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
return await get_music_url(song_info, quality, retry_num + 1)
payload = req.get("data") or {}
sip = (payload.get("sip") or ["http://aqqmusic.tc.qq.com/"])[0]
infos = payload.get("midurlinfo") or []
for index, info in enumerate(infos):
if info.get("purl"):
hit_type = entries[index]["type"] if index < len(entries) else "128k"
return {"type": hit_type, "url": f"{sip}{info['purl']}"}
# 全部受限(VIP 歌匿名)时回退服务器给的 128k m4a 试听文件
if payload.get("testfile2g"):
return {"type": "128k", "url": f"{sip}{payload['testfile2g']}"}
return await get_music_url(song_info, quality, retry_num + 1)
+56
View File
@@ -0,0 +1,56 @@
"""网易云音乐 取流接口(移植自 sdk/apis/wy.ts)。
接口: https://interface3.music.163.com/eapi/song/enhance/player/url/v1(eapi 加密请求,响应为明文 JSON)
音质: 128k → standard, 320k → exhigh, flac → lossless, flac24bit → hires
未登录时不带 cookie 走匿名通道(音质上限由服务端决定),与原实现一致。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_wy_cookie
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.utils import UA_WIN_CHROME
from app.sdk.platforms.wy.utils.crypto import eapi
LEVEL_MAP = {
"128k": "standard",
"320k": "exhigh",
"flac": "lossless",
"flac24bit": "hires",
}
# 官方直连接口支持的音质(sdk/__init__.py 汇总为 support_quality)
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
URL_HOST = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流。失败抛 RuntimeError(文案与原文一致:获取失败)。"""
data = {
"ids": f"[{song_info.get('songmid')}]",
"level": LEVEL_MAP.get(quality, "standard"),
"encodeType": "flac",
}
headers = {"User-Agent": UA_WIN_CHROME, "origin": "https://music.163.com"}
cookie = get_wy_cookie()
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
URL_HOST,
method="post",
headers=headers,
form=eapi("/api/song/enhance/player/url/v1", data),
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取失败")
items = body.get("data") or []
item = items[0] if items else None
if not isinstance(item, dict) or not item.get("url"):
raise RuntimeError("获取失败")
return {"type": quality, "url": item["url"]}
+63
View File
@@ -0,0 +1,63 @@
"""喜马拉雅 URL 获取(对应 sdk/apis/xm.ts,PC 桌面端 baseInfo 通道)。
链路:make_xm_sign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList
→ decode_win_url(AES-ECB,客户端 4.0.14 asar 密钥) → a.xmcdn.com 带签名直链。
实测(2026-10-02)匿名免费曲目 HTTP 200 audio/mp4 可播;付费/VIP 曲 ret=1001(需登录)。
档位命名如 M4A_64/MP3_64/M4A_24/MP3_32/AAC_24(音乐内容另有 MP3_128/M4A_128);
喜马拉雅以有声内容为主,码率低于音乐平台,音质映射到 '64k'/'128k' 桶。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.xm.decoder import decode_win_url
from app.sdk.platforms.xm.music_info import get_track_info, play_url_list
#: 优先 M4A(同码率体积更小),128 档优先 MP3_128
QUALITY_PREFERENCE: dict[str, list[str]] = {
"128k": ["MP3_128", "M4A_128", "M4A_64", "MP3_64", "M4A_24", "MP3_32", "AAC_24"],
"64k": ["M4A_64", "MP3_64", "M4A_24", "MP3_32", "AAC_24"],
}
SUPPORT_QUALITYS: list[str] = ["64k", "128k"]
def _bitrate_of(type_name: str) -> int:
match = re.search(r"(\d+)", str(type_name or ""))
return int(match.group(1)) if match else 0
def _bucket_of(bitrate: int) -> str:
return "128k" if bitrate >= 128 else "64k"
def _resolve_url(raw: str) -> str:
return raw if raw.startswith("http") else decode_win_url(raw)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流:按档位偏好找 playUrlList,未知命名时取码率最高的一条。"""
del retry_num # 契约参数,无重试语义
track_id = str(song_info.get("songmid") or "")
track_info = await get_track_info(track_id)
play_list = play_url_list(track_info)
if not play_list:
raise RuntimeError("无可用播放资源(付费/版权内容需登录)")
preference = QUALITY_PREFERENCE.get(quality, QUALITY_PREFERENCE["128k"])
picked: dict[str, str] | None = None
for want in preference:
hit = next((item for item in play_list if item.get("type") == want and item.get("url")), None)
if hit:
picked = {"type": str(hit["type"]), "url": _resolve_url(str(hit["url"]))}
break
if picked is None:
# 兜底:未知档位命名时取码率最高的一条
candidates = [item for item in play_list if item.get("url")]
if not candidates:
raise RuntimeError("无可用播放资源")
best = sorted(candidates, key=lambda item: _bitrate_of(str(item.get("type") or "")), reverse=True)[0]
picked = {"type": str(best["type"]), "url": _resolve_url(str(best["url"]))}
return {"type": _bucket_of(_bitrate_of(picked["type"])), "url": picked["url"]}
+21
View File
@@ -0,0 +1,21 @@
"""账号登录模块(对应原 sdk/auth/ 与各平台 login/auth 文件)。
每个模块导出一组 async 函数,路由层只依赖这些签名:
wy:qr_key / qr_check / cellphone_login / login_by_cookie / status
kg:login_by_token / qr_key / qr_check / send_captcha / cellphone_login /
password_login / verify_user_info / status / refresh_login
tx:qr_show / qr_check / check_vip / refresh_login
lr:login / status
bili:create_qr_login / check_qr_login / status
约定:
* 成功直接返回 dict(由路由包装成 { code: 0, data })
* 可预期的业务失败抛 ApiError(401/422/502, msg)(路由原样转发状态码)
"""
from __future__ import annotations
__all__ = ["bili", "kg", "lr", "tx", "wy"]
from app.sdk.auth import bili, kg, lr, tx, wy
+223
View File
@@ -0,0 +1,223 @@
"""哔哩哔哩扫码登录(对应 sdk/bili/login.ts,上游 bilibili/login.go + account.go 的 IsVipAccount)。
接口:
* GET passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
* GET passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
凭据落盘到 auth_store 的 ``bili`` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
上游 ``New(cookie)`` 的实例化语义由 ``get_credentials('bili')`` 承担(见 platforms/bili/util.py)。
状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
"""
from __future__ import annotations
import random
import time
from typing import Any
from urllib.parse import parse_qs, urlsplit
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bili.util import REFERER, UA, as_dict
QR_GENERATE_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
QR_POLL_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
#: 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制
_CHINA_IP_FIRST = [
36,
42,
58,
60,
101,
106,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123,
124,
125,
171,
175,
180,
182,
183,
210,
211,
218,
219,
220,
221,
222,
223,
]
_CROSS_DOMAIN_KEYS = ("DedeUserID", "DedeUserID__ckMd5", "SESSDATA", "bili_jct", "sid")
def _random_china_ip() -> str:
first = random.choice(_CHINA_IP_FIRST)
return f"{first}.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}"
def _qr_headers() -> dict[str, str]:
ip = _random_china_ip()
return {"User-Agent": UA, "Referer": REFERER, "X-Forwarded-For": ip, "X-Real-IP": ip}
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
def _cookie_map_from_cross_domain(cross_url: str) -> dict[str, str]:
"""crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备)。"""
jar: dict[str, str] = {}
try:
query = parse_qs(urlsplit(str(cross_url or "")).query)
except ValueError:
return jar
for name in _CROSS_DOMAIN_KEYS:
values = query.get(name)
if values:
jar[name] = values[0]
return jar
def _cookie_string(jar: dict[str, str]) -> str:
return "; ".join(f"{key}={value}" for key, value in jar.items())
async def create_qr_login() -> dict[str, Any]:
"""创建扫码登录会话:返回 ``{key, qrurl, qrimg, expiresAt}``(qrimg 可直接展示)。"""
resp = await http_fetch(QR_GENERATE_API, headers=_qr_headers(), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"获取B站登录二维码失败(HTTP {resp.status_code})")
data = as_dict(body.get("data"))
key = str(data.get("qrcode_key") or "").strip()
if body.get("code") != 0 or not key:
message = body.get("message")
raise RuntimeError(f"获取B站登录二维码失败({body.get('code')}{f': {message}' if message else ''})")
url = str(data.get("url") or "")
qrimg = ""
try:
qrimg = to_data_url(url)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {
"key": key,
"qrurl": url,
"qrimg": qrimg,
# 上游 QRLoginSession.ExpiresAt:3 分钟有效期
"expiresAt": int(time.time()) + 180,
}
async def _fetch_profile(cookie: str) -> dict[str, Any]:
"""拉取账号资料(nav;失败不阻断登录)。"""
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav",
headers={"User-Agent": UA, "Referer": REFERER, "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
data = as_dict(body.get("data"))
return {
"mid": data.get("mid"),
"nickname": data.get("uname"),
"vipType": int(data.get("vipType") or 0),
"vipStatus": int(data.get("vipStatus") or 0),
}
except Exception: # noqa: BLE001 - 资料失败不阻断登录
return {"mid": None, "nickname": None, "vipType": 0, "vipStatus": 0}
async def _poll(key: str) -> tuple[int, list[str], Any]:
"""轮询请求(http_fetch 的 set_cookies 保留全部 Set-Cookie,登录凭据靠它取)。"""
url = f"{QR_POLL_API}?qrcode_key={key}"
resp = await http_fetch(url, headers={"User-Agent": UA, "Referer": REFERER}, timeout=15_000)
return resp.status_code, resp.set_cookies, resp.body
async def check_qr_login(key: str) -> dict[str, Any]:
"""轮询扫码状态。
返回 code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)。
"""
qrcode_key = str(key or "").strip()
if not qrcode_key:
raise RuntimeError("B站扫码 key 为空")
status, cookie_lines, body = await _poll(qrcode_key)
if status != 200:
raise ApiError(502, f"轮询B站扫码状态失败(HTTP {status})")
data = as_dict(body.get("data")) if isinstance(body, dict) else {}
code = int(data.get("code") or 0)
if code == 0:
jar = {**_cookie_map_from_cross_domain(data.get("url") or ""), **_cookie_map(cookie_lines)}
cookie = _cookie_string(jar)
if not cookie:
raise RuntimeError("B站登录成功但未获取到 Cookie,请重试")
profile = await _fetch_profile(cookie)
set_credentials(
"bili",
{
"cookie": cookie,
"refreshToken": str(data.get("refresh_token") or ""),
"mid": profile["mid"],
"nickname": profile["nickname"],
"vipType": profile["vipType"],
"vipStatus": profile["vipStatus"],
},
)
from app.sdk.apis.bili import reset_vip_cache
reset_vip_cache()
return {"code": 803, "message": "登录成功", "status": "success", "profile": profile}
status_map: dict[int, tuple[int, str, str]] = {
86038: (800, "二维码已过期", "expired"),
86090: (802, "已扫码,待确认", "scanned"),
86101: (801, "等待扫码", "waiting"),
}
if code in status_map:
mapped, message, state = status_map[code]
return {"code": mapped, "message": message, "status": state}
message = data.get("message") or f"未知状态 {code}"
return {"code": 800, "message": str(message), "status": "failed"}
def status() -> dict[str, Any]:
"""当前登录态(凭据存在性 + 落盘时保存的账号资料)。"""
cred = get_credentials("bili") or {}
if cred.get("cookie"):
return {
"logged": True,
"nickname": cred.get("nickname"),
"mid": cred.get("mid"),
"vipType": cred.get("vipType") or 0,
"updatedAt": cred.get("updatedAt"),
}
return {"logged": False}
+686
View File
@@ -0,0 +1,686 @@
"""酷狗登录(对应 sdk/auth/kg.ts)—— token 绑定 / 扫码 / 短信验证码 / 账号密码。
与 MakcRe/KuGouMusicApi 协议对齐:
* 扫码走 login-user.kugou.com 的 web 签名通道;
* 短信走 android 签名 + AES/RSA;
* 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
sid/edt 行为指纹,指纹由 kg_simulate.py 服务端生成,无需浏览器 WASM);
* token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定。
设备指纹(guid/mid/dfid)与取流共用 platforms/kg/util.py 的实现(落盘 _kg_device)。
"""
from __future__ import annotations
import base64
import json
import time
from typing import Any
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.http.api_error import ApiError
from app.sdk.auth.kg_simulate import generate_simulate
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, to_md5
from app.sdk.platforms.kg.util import (
ANDROID_SALT,
WEB_SALT,
android_sign,
encode_uri_component,
get_device,
pkcs7_pad,
pkcs7_unpad,
random_string,
)
APPID = 1005
CLIENTVER = 11430
#: 登录专用域按参考实现固定 clientver 20489(qr/短信接口实测通过的版本)
LOGIN_CLIENTVER = 20489
SRC_APPID = 2919
QR_URL_PREFIX = "https://h5.kugou.com/apps/loginQRCode/html/index.html"
PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDIAG7QOELSYoIJvTFJhMpe1s/gbjDJX51HBNnEl5HXqTW6lQ7LC8jr9fWZTwusknp+sVGzwd40MwP6U5yDE27M/X1+UR4tvOGOqp94TJtQ1EPnWGWXngpeIW5GxoQGao1rmYWAu6oi1z9XkChrsUdC6DJE5E221wf/4WLFxwAtRQIDAQAB
-----END PUBLIC KEY-----"""
#: 固定串派生 token 续期密钥(AES-256-CBC key / iv)
AES_KEY_SEED = "90b8382a1bb4ccdcf063102053fd75b8"
AES_IV = "f063102053fd75b8"
# login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
PWD_T1 = (
"562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c0"
"3a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5ad"
"cdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169"
"995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac"
)
PWD_T2 = (
"31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294a"
"eebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db14"
"00fdf49b7cf7e26be2e5af4da7830cc3be4"
)
PWD_T3 = "MCwwLDAsMCwwLDAsMCwwLDA="
PWD_CLIENTVER = 11440
VERIFY_VTYPES = {
16: "QQ 绑定验证",
18: "微信绑定验证",
22: "活体认证",
23: "滑块/图形验证码",
32: "手机短信验证码",
34: "人脸核身",
}
VERIFY_URL_TYPES = {
"KGCodeGT": "极验",
"KGCodeTX": "腾讯",
"KGCodeKG": "酷狗旋转",
"KGCodeKG2": "酷狗滑块",
"KGCodeSM": "数美",
"KGCodeYD": "易盾",
}
_ANDROID_UA = "Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi"
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(PUBLIC_KEY.encode())
return _public_key
def _js_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def _rsa_pkcs1_hex(data: dict[str, Any]) -> str:
"""RSA PKCS#1 v1.5 加密(register_dev 的 p / 登录的 pk 之外的默认形态)。"""
return _load_public_key().encrypt(_js_json(data).encode(), padding.PKCS1v15()).hex()
def _rsa_no_padding_hex(data: dict[str, Any], *, pad_left: bool) -> str:
"""RSA 裸填充加密(128 字节;verify 左补零,登录/续期右补零,与参考实现一致)。"""
payload = _js_json(data).encode()
if len(payload) > 128:
raise RuntimeError("RSA 负载超长")
filler = b"\x00" * (128 - len(payload))
padded = filler + payload if pad_left else payload + filler
numbers = _load_public_key().public_numbers()
cipher = pow(int.from_bytes(padded, "big"), numbers.e, numbers.n).to_bytes(128, "big")
return cipher.hex()
def _aes_cbc_encrypt_hex(data: str, key: str, iv: str) -> str:
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv.encode()))
encryptor = cipher.encryptor()
return (encryptor.update(pkcs7_pad(data.encode())) + encryptor.finalize()).hex()
def _aes_encrypt_random(data: str | dict[str, Any]) -> dict[str, str]:
"""随机密钥 AES-256-CBC:key=md5(rand16),iv=key[16:32]。"""
temp_key = random_string(16)
key_hex = to_md5(temp_key)
text = data if isinstance(data, str) else _js_json(data)
return {"str": _aes_cbc_encrypt_hex(text, key_hex, key_hex[16:32]), "key": temp_key}
def _aes_decrypt_hex(hex_text: str, temp_key: str) -> Any:
key_hex = to_md5(temp_key)
decryptor = Cipher(algorithms.AES(key_hex.encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(bytes.fromhex(hex_text)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _playlist_aes_encrypt(data: dict[str, Any]) -> dict[str, str]:
key = random_string(6)
key_hex = to_md5(key)
cipher = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode()))
encryptor = cipher.encryptor()
payload = encryptor.update(pkcs7_pad(_js_json(data).encode())) + encryptor.finalize()
return {"key": key, "str": base64.b64encode(payload).decode()}
def _playlist_aes_decrypt(raw_base64: str, key: str) -> Any:
key_hex = to_md5(key)
decryptor = Cipher(algorithms.AES(key_hex[:16].encode()), modes.CBC(key_hex[16:32].encode())).decryptor()
out = decryptor.update(base64.b64decode(raw_base64)) + decryptor.finalize()
text = pkcs7_unpad(out).decode("utf-8", errors="replace")
try:
return json.loads(text)
except ValueError:
return text
def _int_or_zero(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _gateway_headers(params: dict[str, Any], router: str | None, extra: dict[str, str] | None) -> dict[str, str]:
headers = {
"dfid": str(params["dfid"]),
"mid": str(params["mid"]),
"clienttime": str(params["clienttime"]),
"kg-rc": "1",
"kg-thash": "5d816a0",
"kg-rec": "1",
"kg-rf": "B9EDA08A64250DEFFBCADDEE00F8F25F",
"User-Agent": _ANDROID_UA,
}
if router:
headers["x-router"] = router
if extra:
headers.update(extra)
return headers
async def gateway_request(
url: str,
*,
params: dict[str, Any] | None = None,
data: Any = None,
router: str | None = None,
host: str = "https://gateway.kugou.com",
headers: dict[str, str] | None = None,
with_headers: bool = False,
) -> Any:
"""网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域)。"""
dev = get_device()
all_params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
}
if params:
all_params.update(params)
signature = android_sign(all_params, _js_json(data) if data is not None else "")
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in all_params.items())
kwargs: dict[str, Any] = {
"method": "POST",
"headers": _gateway_headers(all_params, router, headers),
"timeout": 15_000,
}
if data is not None:
kwargs["body"] = data
resp = await http_fetch(f"{host}{url}?{query}&signature={signature}", **kwargs)
body = as_dict(resp.body)
if with_headers:
return body, resp.headers
return body
def _web_sign(params: dict[str, Any]) -> str:
items = sorted(f"{key}={value}" for key, value in params.items())
return to_md5(f"{WEB_SALT}{''.join(items)}{WEB_SALT}")
async def _login_user_get(url: str, extra_params: dict[str, Any] | None = None) -> dict[str, Any]:
"""login-user.kugou.com GET 请求(web 签名,与取流共用同一设备指纹)。"""
dev = get_device()
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": LOGIN_CLIENTVER,
"clienttime": int(time.time()),
}
if extra_params:
params.update(extra_params)
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://login-user.kugou.com{url}?{query}&signature={_web_sign(params)}",
headers=_gateway_headers(params, None, None),
timeout=15_000,
)
body = as_dict(resp.body)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or "酷狗登录服务请求失败"))
return body
async def verify_token(token: str, userid: str | int) -> dict[str, Any]:
"""验证 token 并返回账号资料(usercenter /v3/get_my_info,p = RSA 裸填充左补零)。"""
unix_sec = int(time.time())
p = _rsa_no_padding_hex({"token": token, "clienttime": unix_sec}, pad_left=True).upper()
body = await gateway_request(
"/v3/get_my_info",
router="usercenter.kugou.com",
params={"plat": 1},
data={"visit_time": unix_sec, "usertype": 1, "p": p, "userid": _int_or_zero(userid)},
)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data:
raise ApiError(401, str(body.get("error") or "token 无效或已过期"))
return {
"userid": data.get("userid") or _int_or_zero(userid),
"nickname": data.get("nickname") or data.get("username") or None,
"vip": bool(data["vip"]) if data.get("vip") else None,
}
async def login_by_token(token: str, userid: str | int) -> dict[str, Any]:
"""绑定并验证 kg token。"""
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
async def _apply_kg_login(token: str, userid: Any) -> dict[str, Any]:
"""保存登录凭据并拉取账号资料(扫码/短信登录共用;资料校验失败不阻断登录)。"""
try:
profile = await verify_token(token, userid)
set_credentials("kg", {"token": token, "userid": profile["userid"], "nickname": profile["nickname"]})
return profile
except Exception: # noqa: BLE001 - 资料校验失败不影响登录本身
uid = _int_or_zero(userid)
set_credentials("kg", {"token": token, "userid": uid})
return {"userid": uid, "nickname": None, "vip": None}
# ---- 扫码登录 ----
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码(qrimg 为可直接展示的 data URL)。"""
body = await _login_user_get(
"/v2/qrcode",
{
"appid": 1001,
"type": 1,
"plat": 4,
"qrcode_txt": f"{QR_URL_PREFIX}?appid={APPID}&",
"srcappid": SRC_APPID,
},
)
data = as_dict(body.get("data"))
key = data.get("qrcode")
if not key:
raise RuntimeError("获取二维码 key 失败")
qrurl = f"{QR_URL_PREFIX}?qrcode={key}"
qrimg = str(data.get("qrcode_img") or "")
if not qrimg:
from app.sdk.common.qrcode import to_data_url
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态(酷狗原生 0/1/2/4 → 800/801/802/803)。"""
body = await _login_user_get(
"/v2/get_userinfo_qrcode",
{"plat": 4, "appid": APPID, "srcappid": SRC_APPID, "qrcode": key},
)
data = as_dict(body.get("data"))
qr_status = data.get("status")
if qr_status == 4:
token = data.get("token")
if not token:
raise RuntimeError("登录响应中缺少 token")
return {"code": 803, "message": "登录成功", "profile": await _apply_kg_login(token, data.get("userid"))}
mapping: dict[Any, tuple[int, str]] = {0: (800, "二维码已过期"), 1: (801, "等待扫码"), 2: (802, "已扫码,待确认")}
code, message = mapping.get(qr_status, (qr_status, f"未知状态 {qr_status}"))
return {"code": code, "message": message}
# ---- 手机验证码登录 ----
def _sign_login_key(clienttime_ms: int) -> str:
return to_md5(f"{APPID}{ANDROID_SALT}{LOGIN_CLIENTVER}{clienttime_ms}")
async def send_captcha(mobile: str) -> dict[str, Any]:
"""发送短信验证码(businessid 5 = 登录场景)。"""
body = await gateway_request(
"/v7/send_mobile_code",
router="login.user.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={"businessid": 5, "mobile": str(mobile), "plat": 3},
)
if body.get("status") != 1:
raise ApiError(502, str(body.get("error") or body.get("msg") or "验证码发送失败"))
return {"sent": True}
async def cellphone_login(mobile: str, code: str) -> dict[str, Any]:
"""手机验证码登录(mobile + code,成功后自动保存凭据并返回账号资料)。"""
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"mobile": str(mobile), "code": str(code)})
body = await gateway_request(
"/v7/login_by_verifycode",
host="https://loginserviceretry.kugou.com",
params={"clientver": LOGIN_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"t1": 0,
"t2": 0,
"clienttime_ms": clienttime_ms,
"mobile": f"{str(mobile)[:2]}*****{str(mobile)[10:11]}",
"key": _sign_login_key(clienttime_ms),
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
"params": enc["str"],
"t3": "MCwwLDAsMCwwLDAsMCwwLDA=",
},
headers={"User-Agent": "Android16-1070-11440-130-0-LOGIN-wifi", "support-calm": "1"},
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
data = as_dict(body.get("data"))
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"]) if data.get("secu_params") else None
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
# ---- token 自动续期 ----
async def refresh_login() -> dict[str, Any]:
"""刷新 kg token(login_by_token),成功后写回凭据存储。"""
cred = get_credentials("kg") or {}
if not cred.get("token") or str(cred.get("userid") or "") in ("", "0"):
return {"skipped": True}
clienttime = int(time.time())
p3 = _aes_cbc_encrypt_hex(
_js_json({"clienttime": clienttime, "token": cred["token"]}), to_md5(AES_KEY_SEED), AES_IV
)
enc = _aes_encrypt_random({})
pk = _rsa_no_padding_hex({"clienttime_ms": clienttime * 1000, "key": enc["key"]}, pad_left=False)
body = await gateway_request(
"/v5/login_by_token",
router="login.user.kugou.com",
params={"clientver": 20349},
data={
"p3": p3,
"params": enc["str"],
"userid": _int_or_zero(cred.get("userid")),
"need_toneinfo": 1,
"clienttime_ms": clienttime * 1000,
"dfid": "-",
"dev": "SDY-AN00",
"plat": 1,
"pk": pk,
"t1": "0",
"gitversion": "a23c277",
"t2": "0",
"t3": "MCwwLDEsMSwwLDYsMSw2LDA=",
},
headers={
"kg-thash": "6a6a1ba",
"User-Agent": "Android12-AndroidPhone-20349-201-0-ting#958959317/661004247-LOGIN-wifi",
"support-calm": "1",
},
)
data = as_dict(body.get("data"))
if body.get("error_code") != 0 or not data.get("secu_params"):
error = body.get("error")
raise RuntimeError(f"刷新失败(code {body.get('error_code')}{f': {error}' if error else ''})")
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
if not isinstance(decrypted, dict) or not decrypted.get("token"):
raise RuntimeError("刷新响应解密失败")
set_credentials("kg", {"token": decrypted["token"]})
return {"refreshed": True}
# ---- 设备 dfid 正式注册 ----
async def register_dfid() -> dict[str, Any]:
"""注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍)。
随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
协议:设备画像 → AES-128-CBC(随机 6 位 key) → base64 作 body;
p = RSA-PKCS1({aes:key, uid, token});响应为二进制密文,按 base64 解码后同密钥解密。
"""
dev = get_device()
if dev.get("dfidRegistered"):
return dev
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
device_map = {
"availableRamSize": 4983533568,
"availableRomSize": 48114719,
"availableSDSize": 48114717,
"basebandVer": "",
"batteryLevel": 100,
"batteryStatus": 3,
"brand": "Redmi",
"buildSerial": "unknown",
"device": "marble",
"imei": dev["guid"],
"imsi": "",
"manufacturer": "Xiaomi",
"uuid": dev["guid"],
"accelerometer": False,
"accelerometerValue": "",
"gravity": False,
"gravityValue": "",
"gyroscope": False,
"gyroscopeValue": "",
"light": False,
"lightValue": "",
"magnetic": False,
"magneticValue": "",
"orientation": False,
"orientationValue": "",
"pressure": False,
"pressureValue": "",
"step_counter": False,
"step_counterValue": "",
"temperature": False,
"temperatureValue": "",
}
enc = _playlist_aes_encrypt(device_map)
p = _rsa_pkcs1_hex({"aes": enc["key"], "uid": userid, "token": cred.get("token") or ""})
params: dict[str, Any] = {
"dfid": dev["dfid"],
"mid": dev["mid"],
"uuid": "-",
"appid": APPID,
"clientver": CLIENTVER,
"clienttime": int(time.time()),
"part": 1,
"platid": 1,
"p": p,
}
params["signature"] = android_sign(params, enc["str"])
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in params.items())
resp = await http_fetch(
f"https://userservice.kugou.com/risk/v2/r_register_dev?{query}",
method="POST",
headers={
"dfid": str(dev["dfid"]),
"mid": str(dev["mid"]),
"clienttime": str(params["clienttime"]),
"User-Agent": _ANDROID_UA,
},
body=enc["str"],
timeout=15_000,
)
raw_b64 = (
base64.b64encode(resp.raw).decode()
if resp.raw
else base64.b64encode(_js_json(resp.body or "").encode()).decode()
)
body = _playlist_aes_decrypt(raw_b64, enc["key"])
body = as_dict(body)
data = as_dict(body.get("data"))
if body.get("status") != 1 or not data.get("dfid"):
raise RuntimeError(f"dfid 注册失败(status {body.get('status', '未知')} {body.get('error', '')})".strip())
updated = {**dev, "dfid": str(data["dfid"]), "dfidRegistered": True}
set_credentials("_kg_device", updated)
return updated
# ---- 账号密码登录 + 20028 验证码挑战 ----
async def get_verify_info(eventid: str) -> dict[str, Any]:
"""查询验证事件详情(/verifyservice/v3/get_verify_info)。"""
cred = get_credentials("kg") or {}
body = await gateway_request(
"/verifyservice/v3/get_verify_info",
data={
"eventid": eventid,
"userid": _int_or_zero(cred.get("userid")),
"platid": 2,
"rtype": 1,
"wasm": 1,
"i": "",
"sid": "",
"edt": "",
},
)
if body.get("status") != 1 and body.get("error_code") != 0:
raise RuntimeError(str(body.get("error") or f"查询验证信息失败(code {body.get('error_code', '未知')})"))
data = body.get("data")
return data if isinstance(data, dict) else {}
async def verify_user_info(
eventid: str,
v_type: int,
verifycode: str,
sid: str | None = None,
edt: str | None = None,
) -> dict[str, Any]:
"""提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码)。"""
dev = get_device()
cred = get_credentials("kg") or {}
userid = _int_or_zero(cred.get("userid"))
fingerprint = {"sid": sid, "edt": edt} if sid and edt else generate_simulate(dev["mid"], userid, dev["dfid"])
data_map: dict[str, Any] = {
"eventid": eventid,
"userid": userid,
"platid": 2,
"v_type": _int_or_zero(v_type) or 23,
"wasm": 1,
"i": "",
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
enc = _aes_encrypt_random({"code": verifycode} if data_map["v_type"] == 32 else {})
if data_map["v_type"] == 32:
# 手机短信验证码:明文 code + 密文 params 均携带
data_map.update({"code": verifycode, "verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
else:
# 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
data_map.update({"verifycode": verifycode})
data_map["pk"] = _rsa_no_padding_hex({"key": enc["key"]}, pad_left=False)
data_map["params"] = enc["str"]
body = await gateway_request(
"/v4/verify_user_info",
host="https://verifyservice.kugou.com",
params={"clientver": 11510},
data=data_map,
)
if body.get("status") != 1:
raise ApiError(401, str(body.get("error") or f"验证提交失败(code {body.get('error_code', '未知')})"))
return {"verified": True}
async def password_login(username: str, password: str, code: str | None = None) -> dict[str, Any]:
"""账号密码登录(/v9/login_by_pwd);命中 20028 风控返回验证码挑战。"""
# 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
try:
await register_dfid()
except Exception: # noqa: BLE001 - 注册失败不阻断登录
pass
clienttime_ms = int(time.time() * 1000)
enc = _aes_encrypt_random({"pwd": str(password), "code": str(code or ""), "clienttime_ms": clienttime_ms})
body, headers = await gateway_request(
"/v9/login_by_pwd",
router="login.user.kugou.com",
params={"clientver": PWD_CLIENTVER},
data={
"plat": 1,
"support_multi": 1,
"clienttime_ms": clienttime_ms,
"t1": PWD_T1,
"t2": PWD_T2,
"t3": PWD_T3,
"dev": "SDY-AN00",
"username": str(username),
"params": enc["str"],
"pk": _rsa_no_padding_hex({"clienttime_ms": clienttime_ms, "key": enc["key"]}, pad_left=False).upper(),
},
headers={"User-Agent": f"Android16-1070-{PWD_CLIENTVER}-130-0-LOGIN-wifi", "support-calm": "1"},
with_headers=True,
)
data = as_dict(body.get("data"))
if body.get("status") == 1 and data.get("secu_params"):
decrypted = _aes_decrypt_hex(str(data["secu_params"]), enc["key"])
token = decrypted.get("token") if isinstance(decrypted, dict) else decrypted
if not token:
raise RuntimeError("登录响应解密失败")
userid = decrypted.get("userid") if isinstance(decrypted, dict) else None
return await _apply_kg_login(str(token), userid or data.get("userid") or 0)
if body.get("error_code") == 20028:
eventid = str(headers.get("ssa-code") or body.get("ssaCode") or "")
if not eventid:
raise RuntimeError("触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录")
dev = get_device()
cred = get_credentials("kg") or {}
fingerprint = generate_simulate(dev["mid"], _int_or_zero(cred.get("userid")), dev["dfid"])
try:
info = await get_verify_info(eventid)
except Exception: # noqa: BLE001 - 挑战详情失败时按默认类型返回
info = {}
v_type = _int_or_zero(info.get("v_type")) or 23
url = str(info.get("url") or "")
provider = next((key for key in VERIFY_URL_TYPES if key in url), "")
challenge: dict[str, Any] = {
"eventid": eventid,
"v_type": v_type,
"vTypeLabel": VERIFY_VTYPES.get(v_type, f"未知类型 {v_type}"),
"url": url or None,
"provider": VERIFY_URL_TYPES[provider] if provider else None,
"txappid": info.get("txappid"),
"sessionid": info.get("sessionid"),
"sid": fingerprint["sid"],
"edt": fingerprint["edt"],
}
return challenge
raise ApiError(401, str(body.get("error") or f"登录失败(code {body.get('error_code', '未知')})"))
async def status() -> dict[str, Any] | None:
"""当前 kg 登录资料(未登录返回 None)。"""
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"userid": cred.get("userid"), "nickname": cred.get("nickname")}
+161
View File
@@ -0,0 +1,161 @@
"""酷狗行为指纹模拟(对应 sdk/auth/kgSimulate.ts)—— sid/edt 生成。
移植自 MakcRe/KuGouMusicApi util/generate_simulate.js,用途:20028 二次验证时替代浏览器 WASM 上报行为数据。
协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流>
* edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64
* sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制)
事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔,
每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。
"""
from __future__ import annotations
import base64
import math
import random
import time
from typing import Any
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.utils import to_md5
from app.sdk.platforms.kg.util import pkcs7_pad, random_string
SIM_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF
lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/
W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA
2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW
PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc
56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa
rQIDAQAB
-----END PUBLIC KEY-----"""
SIM_IV = b"kugousecurity123"
def _ri(low: int, high: int) -> int:
"""JS ``Math.floor(Math.random() * (max - min + 1)) + min``。"""
return math.floor(random.random() * (high - low + 1)) + low
def _round(value: float) -> int:
"""JS Math.round(.5 向上取整,不是 Python 的银行家舍入)。"""
return math.floor(value + 0.5)
_sentinel = 0xFFFFFFFF - _ri(0, 20)
def _f3(t: int | str, i: int, x: int, y: int) -> str:
return f"3,{t},{i},{x},{y}"
def _f5(t: int | str, i: int) -> str:
return f"5,{t},{i}"
def _f6(t: int | str, i: int, x: int, y: int) -> str:
return f"6,{t},{i},{x},{y}"
def _bezier_path(sx: float, sy: float, ex: float, ey: float, n: int) -> list[dict[str, float]]:
"""三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛。"""
c1x = sx + (ex - sx) * 0.3 + _ri(-80, 80)
c1y = sy + (ey - sy) * 0.2 + _ri(-60, 60)
c2x = sx + (ex - sx) * 0.7 + _ri(-60, 60)
c2y = sy + (ey - sy) * 0.8 + _ri(-40, 40)
points: list[dict[str, float]] = []
for i in range(n + 1):
t = i / n
u = 1 - t
x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
jitter = max(0.5, 3 - t * 2.5)
points.append(
{
"x": x + (random.random() - 0.5) * jitter,
"y": y + (random.random() - 0.5) * jitter,
}
)
return points
def _generate_edt_data(start_x: int, start_y: int, end_x: int, end_y: int, mouse_points: int) -> str:
entries: list[str] = []
ts = 0
ei = 0
entries.extend([_f5(0, 0), _f5(_sentinel, 0), _f5(0, 0), _f5(_sentinel, 0)])
ts += _ri(5, 20)
entries.extend([_f6(ts, ei, 750, 500), _f6(_sentinel, ei, 750, 500)])
ei += 1
for _ in range(3):
ts += _ri(80, 600)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
path = _bezier_path(start_x, start_y, end_x, end_y, mouse_points)
si = 0
for i, point in enumerate(path):
ts += _ri(8, 50)
rx = _round(point["x"])
ry = _round(point["y"])
entries.extend([_f3(ts, si, rx, ry), _f3(_sentinel, si, rx, ry)])
if i > 0 and i % 12 == 0:
ts += _ri(20, 60)
entries.extend([_f5(ts, ei), _f5(_sentinel, ei)])
ei += 1
si = (si + 1) % 2
ts += _ri(5, 30)
entries.extend(
[
_f3(ts, 1, _round(end_x + _ri(-5, 5)), _round(end_y + _ri(-5, 5))),
_f3(_sentinel, 1, _round(end_x), _round(end_y)),
]
)
return ":".join(entries)
_public_key = None
def _load_public_key() -> Any:
global _public_key
if _public_key is None:
_public_key = serialization.load_pem_public_key(SIM_PUBLIC_KEY.encode())
return _public_key
def generate_simulate(mid: str | int, userid: str | int, dfid: str, webgl_hash: str = "") -> dict[str, str]:
"""生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送。"""
global _sentinel
_sentinel = 0xFFFFFFFF - _ri(0, 20)
key = to_md5(random_string(16))[:16]
points = _ri(30, 60)
start_x = _ri(200, 600)
start_y = _ri(200, 500)
end_x = _ri(500, 700)
end_y = _ri(80, 150)
webgl = webgl_hash or to_md5(random_string(32))
edt_data = _generate_edt_data(start_x, start_y, end_x, end_y, points)
plaintext = (
f"mid={mid or 0};userid={userid or 0};dfid={dfid or 0};webgl={webgl};"
f"webdriver=0;ts={int(time.time() * 1000)};data={edt_data}"
)
cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(SIM_IV))
encryptor = cipher.encryptor()
edt = base64.b64encode(encryptor.update(pkcs7_pad(plaintext.encode())) + encryptor.finalize()).decode()
sid = base64.b64encode(
_load_public_key().encrypt(
key.encode(),
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
)
).decode()
return {"edt": edt, "sid": sid}
+59
View File
@@ -0,0 +1,59 @@
"""懒人听书登录(对应 sdk/lr/auth.ts,web 通道 m.lrts.me/ajax/logon)。
参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
(免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
"""
from __future__ import annotations
import base64
from typing import Any
from urllib.parse import quote
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
UA_MI = (
"Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0"
)
LOGON_URL = "https://m.lrts.me/ajax/logon"
async def login(account: str, password: str) -> dict[str, Any]:
"""账号密码登录(成功保存会话 Cookie)。"""
form = f"account={quote(str(account), safe='')}&pwd={base64.b64encode(str(password).encode()).decode()}"
resp = await http_fetch(
LOGON_URL,
method="POST",
headers={
"User-Agent": UA_MI,
"Referer": "https://m.lrts.me/",
"Content-Type": "application/x-www-form-urlencoded",
},
body=form,
timeout=15_000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not body.get("phone"):
raise ApiError(401, str(body.get("msg") or "登录失败(账号或密码错误)"))
cookie = "; ".join(part for part in (item.split(";")[0].strip() for item in resp.set_cookies) if part)
if not cookie:
raise RuntimeError("登录成功但未返回会话 Cookie")
set_credentials("lr", {"cookie": cookie, "phone": str(body["phone"])})
return {"logged": True, "phone": str(body["phone"])}
def get_lr_cookie() -> str:
"""登录会话 Cookie(未登录返回空串)。"""
return str((get_credentials("lr") or {}).get("cookie") or "")
def status() -> dict[str, Any]:
"""登录状态。"""
cred = get_credentials("lr") or {}
if cred.get("cookie"):
return {"logged": True, "phone": cred.get("phone"), "updatedAt": cred.get("updatedAt")}
return {"logged": False}
+382
View File
@@ -0,0 +1,382 @@
"""QQ 音乐凭据(对应 sdk/auth/tx.ts)—— cookie 解析、扫码登录、VIP 检测与登录态续期。
续期走 musicu.fcg 未签名通道(与 apis/tx.py 一致);Login/VipLogin 模块若拒绝未签名请求
则以 code != 0 失败,此时保留原凭据并抛错,由调用方记录日志。
续期条件:仅浏览器 cookie(无 openId 等抓包字段)时只带 musicid/musickey 尝试,失败率较高;
完整凭据(/login/tx 可选传入 openId/accessToken/refreshToken/refreshKey)最可靠。
扫码登录(参考 sansenjian/qq-music-api):ptlogin 扫码 → check_sig →
graph.qq.com OAuth authorize 换 code → QQConnectLogin.LoginServer/QQLogin 换凭据,
成功响应体一般携带 refresh_key 等完整字段,可走自动续期。
"""
from __future__ import annotations
import base64
import random
import re
import time
from typing import Any
from urllib.parse import urlencode
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)",
"Referer": "https://y.qq.com/",
}
def parse_cookie(cookie: str) -> dict[str, str]:
"""解析 cookie 中的关键字段(uin / qm_keyst 即 musickey / tmeLoginType)。"""
text = str(cookie or "")
uin = re.search(r"uin=(\d+)", text)
musickey = re.search(r"qm_keyst=([^;]+)", text)
login_type = re.search(r"tmeLoginType=(\d+)", text)
return {
"uin": uin.group(1) if uin else "0",
"musickey": musickey.group(1) if musickey else "",
"tmeLoginType": login_type.group(1) if login_type else "",
}
async def _musicu_request(data: dict[str, Any], cred: dict[str, Any] | None) -> dict[str, Any]:
headers = dict(HEADERS)
cookie = str((cred or {}).get("cookie") or "")
if cookie:
headers["cookie"] = cookie
resp = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers=headers,
body=data,
timeout=15_000,
)
return resp.body if isinstance(resp.body, dict) else {}
async def check_vip() -> str | None:
"""检测当前账号 VIP 等级(vip_login_base),结果写入凭据存储。
成功返回 'svip' / 'vip' / 'normal';请求失败返回 None(不改动已存值)。
"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return None
uin = parse_cookie(str(cred["cookie"]))["uin"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "VipLogin.VipLoginInter", "method": "vip_login_base", "param": {}},
},
cred,
)
req = as_dict(body.get("req"))
if req.get("code") != 0:
return None
data = as_dict(req.get("data"))
identity = as_dict(data.get("identity"))
if identity.get("HugeVip"):
vip_type = "svip"
elif identity.get("LMFlag"):
vip_type = "vip"
else:
vip_type = "normal"
set_credentials("tx", {"vipType": vip_type})
return vip_type
async def refresh_login() -> dict[str, Any]:
"""续期 tx 登录态(music.login.LoginServer),成功后写回新 musickey 并顺带检测 VIP。"""
cred = get_credentials("tx")
if not cred or not cred.get("cookie"):
return {"skipped": True}
parsed = parse_cookie(str(cred["cookie"]))
uin, musickey = parsed["uin"], parsed["musickey"]
if not musickey:
raise RuntimeError("cookie 缺少 qm_keyst 字段")
param: dict[str, Any] = {
"musicid": int(uin) if uin.isdigit() else 0,
"musickey": musickey,
"loginMode": 2,
"expired_in": 0,
}
# App 抓包的完整凭据(存在才携带)
if cred.get("openId"):
param["openid"] = cred["openId"]
if cred.get("accessToken"):
param["access_token"] = cred["accessToken"]
if cred.get("refreshToken"):
param["refresh_token"] = cred["refreshToken"]
if cred.get("refreshKey"):
param["refresh_key"] = cred["refreshKey"]
body = await _musicu_request(
{
"comm": {"ct": "19", "cv": "1859", "uin": uin},
"req": {"module": "music.login.LoginServer", "method": "Login", "param": param},
},
cred,
)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
if req.get("code") != 0 or not data.get("musickey"):
msg = f": {req['msg']}" if req.get("msg") else ""
raise RuntimeError(f"刷新失败(code {req.get('code')}{msg})")
# musickey 可能含 $ 等字符:用函数替换,避免字符串替换里的分组转义被展开
cookie = re.sub(r"qm_keyst=[^;]*", lambda _match: f"qm_keyst={data['musickey']}", str(cred["cookie"]))
updated: dict[str, Any] = {"cookie": cookie, "uin": str(data.get("musicid") or uin)}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响续期结果
vip_type = None
return {"refreshed": True, **({"vipType": vip_type} if vip_type else {})}
# ---------------- 扫码登录 ----------------
def _int32(value: int) -> int:
"""JS 位运算的 ToInt32 语义。"""
value &= 0xFFFFFFFF
return value - 0x100000000 if value >= 0x80000000 else value
def _hash33(text: str) -> int:
"""ptqrtoken:hash33(qrsig)。"""
hashed = 0
for char in text:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _get_gtk(skey: str) -> int:
"""g_tk:p_skey 的 5381 哈希。"""
hashed = 5381
for char in skey:
shifted = _int32(_int32(hashed) << 5)
hashed = _int32(hashed + shifted + ord(char))
return hashed & 0x7FFFFFFF
def _random_guid() -> str:
def repl(match: re.Match[str]) -> str:
value = int(random.random() * 16) | 0
if match.group(0) == "y":
value = (value & 0x3) | 0x8
return format(value, "X")
return re.sub(r"[xy]", repl, "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx")
class _CookieJar:
"""登录链路共享的 cookie 罐:同名后到优先,存「name=value」对。"""
def __init__(self) -> None:
self._jar: dict[str, str] = {}
def merge(self, set_cookies: list[str] | None) -> None:
for item in set_cookies or []:
pair = str(item).split(";")[0]
name = pair.split("=")[0].strip() if "=" in pair else ""
if name:
self._jar[name] = pair
def get(self, name: str) -> str:
pair = self._jar.get(name)
return "=".join(pair.split("=")[1:]) if pair else ""
def entries(self) -> list[tuple[str, str]]:
return list(self._jar.items())
def header(self) -> str:
return "; ".join(self._jar.values())
class _PtloginError(RuntimeError):
def __init__(self, status_code: int, message: str) -> None:
super().__init__(message)
self.status_code = status_code
async def _ptlogin_request(path: str, query: dict[str, str], cookie: str) -> Any:
url = f"https://ssl.ptlogin2.qq.com/{path}?{urlencode(query)}"
kwargs: dict[str, Any] = {"timeout": 15_000}
if cookie:
kwargs["headers"] = {"cookie": cookie}
resp = await http_fetch(url, **kwargs)
if resp.status_code != 200:
raise _PtloginError(resp.status_code, f"ptlogin {path} 失败(HTTP {resp.status_code})")
return resp
async def qr_show() -> dict[str, Any]:
"""创建扫码登录二维码(客户端扫码后轮询 qr_check),qrsig 需原样回传。"""
resp = await http_fetch(
"https://ssl.ptlogin2.qq.com/ptqrshow?appid=716027609&e=2&l=M&s=3&d=72&v=4"
f"&t={random.random()}&daid=383&pt_3rd_aid=100497308"
"&u1=https%3A%2F%2Fgraph.qq.com%2Foauth2.0%2Flogin_jump",
timeout=15_000,
)
qrsig = ""
for item in resp.set_cookies:
match = re.search(r"qrsig=([^;]+)", item)
if match:
qrsig = match.group(1)
break
if not qrsig:
raise RuntimeError("获取二维码失败(响应缺少 qrsig)")
return {"qrimg": f"data:image/png;base64,{base64.b64encode(resp.raw).decode()}", "qrsig": qrsig}
async def qr_check(qrsig: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 成功(凭据已保存)。"""
if not qrsig:
raise ApiError(422, "缺少 qrsig")
jar = _CookieJar()
jar.merge([f"qrsig={qrsig}"])
# 1. 轮询扫码状态:0 成功 / 65 已失效 / 66 未扫描 / 67 已扫描 / 68 授权中
# qrsig 无效/已被 ptlogin 下线时返回 403,按二维码过期处理
try:
poll = await _ptlogin_request(
"ptqrlogin",
{
"u1": "https://graph.qq.com/oauth2.0/login_jump",
"ptqrtoken": str(_hash33(qrsig)),
"ptredirect": "0",
"h": "1",
"t": "1",
"g": "1",
"from_ui": "1",
"ptlang": "2052",
"action": f"0-0-{int(time.time() * 1000)}",
"js_ver": "23111510",
"js_type": "1",
"pt_uistyle": "40",
"aid": "716027609",
"daid": "383",
"pt_3rd_aid": "100497308",
"o1vId": _random_guid(),
},
jar.header(),
)
except _PtloginError as err:
if err.status_code == 403:
return {"code": 800, "message": "二维码已失效"}
raise
jar.merge(poll.set_cookies)
text = poll.body if isinstance(poll.body, str) else ""
match = re.search(r"ptuiCB\('(\d+)','[^']*','([^']*)'", text)
state = match.group(1) if match else ""
if state != "0":
states = {
"65": (800, "二维码已失效"),
"66": (801, "等待扫码"),
"67": (802, "已扫码,待确认"),
"68": (802, "授权中"),
}
code, message = states.get(state, (801, "等待扫码"))
return {"code": code, "message": message}
# 2. check_sig 换 p_skey(g_tk 需要)
check_sig_url = match.group(2) if match else ""
if not check_sig_url.startswith("http"):
raise RuntimeError("登录检查失败(未返回 check_sig 地址)")
check_sig = await http_fetch(check_sig_url, headers={"cookie": jar.header()}, timeout=15_000)
jar.merge(check_sig.set_cookies)
p_skey = jar.get("p_skey")
if not p_skey:
raise RuntimeError("登录检查失败(响应缺少 p_skey)")
# 3. OAuth authorize 换 code(appid 100497308 为 QQ 音乐开放平台)
authorize = await http_fetch(
"https://graph.qq.com/oauth2.0/authorize",
method="POST",
headers={"Referer": "https://graph.qq.com/oauth2.0/authorize", "cookie": jar.header()},
form={
"response_type": "code",
"client_id": "100497308",
"redirect_uri": "https://y.qq.com/portal/wx_redirect.html?login_type=1&surl=https://y.qq.com/",
"scope": "get_user_info,get_app_friends",
"state": "state",
"switch": "",
"from_ptlogin": "1",
"src": "1",
"update_auth": "1",
"openapi": "1010_1030",
"g_tk": str(_get_gtk(p_skey)),
"auth_time": str(int(time.time() * 1000)),
"ui": _random_guid(),
},
timeout=15_000,
)
jar.merge(authorize.set_cookies)
location = authorize.headers.get("location", "")
code_match = re.search(r"[?&]code=([^&]+)", location)
if not code_match:
raise RuntimeError("OAuth 授权失败(未取得 code)")
code = code_match.group(1)
# 4. QQLogin 换凭据:响应体 req.data 携带 musickey/musicid(及 openid 等续期字段)
login = await http_fetch(
"https://u.y.qq.com/cgi-bin/musicu.fcg",
method="POST",
headers={**HEADERS, "cookie": jar.header()},
body={
"comm": {"g_tk": _get_gtk(p_skey), "platform": "yqq", "ct": 24, "cv": 0},
"req": {"module": "QQConnectLogin.LoginServer", "method": "QQLogin", "param": {"code": code}},
},
timeout=15_000,
)
jar.merge(login.set_cookies)
body = as_dict(login.body)
req = as_dict(body.get("req"))
data = as_dict(req.get("data"))
musickey = str(data.get("musickey") or jar.get("qm_keyst") or "")
if not musickey:
raise RuntimeError("登录失败(未取得 musickey)")
# ptlogin 域的 uin 可能带 o 前缀(如 o0123456),统一剥掉
uin = re.sub(r"^o0*", "", str(data.get("musicid") or jar.get("uin") or "")) or "0"
# 最终 cookie:uin/qm_keyst 用凭据值放最前,其余链路 cookie 原样保留(排除 ptlogin 域噪声)
pairs = [f"uin={uin}", f"qm_keyst={musickey}"]
if data.get("encryptUin"):
pairs.append(f"euin={data['encryptUin']}")
skip = {"qrsig", "ptui_loginuin", "uin", "qm_keyst", "euin"}
for name, pair in jar.entries():
if name not in skip:
pairs.append(pair)
updated: dict[str, Any] = {"cookie": "; ".join(pairs), "uin": uin}
if data.get("openid"):
updated["openId"] = str(data["openid"])
if data.get("access_token"):
updated["accessToken"] = str(data["access_token"])
if data.get("refresh_token"):
updated["refreshToken"] = str(data["refresh_token"])
if data.get("refresh_key"):
updated["refreshKey"] = str(data["refresh_key"])
set_credentials("tx", updated)
try:
vip_type = await check_vip()
except Exception: # noqa: BLE001 - VIP 检测失败不影响登录结果
vip_type = None
return {"code": 803, "message": "登录成功", "uin": uin, **({"vipType": vip_type} if vip_type else {})}
+166
View File
@@ -0,0 +1,166 @@
"""网易云登录(对应 sdk/auth/wy.ts)—— 扫码 / 手机号 / cookie 绑定,weapi 加密请求。
参考实现:NeteaseCloudMusicApi(login_qr_key / login_qr_check / login_cellphone / login_status)。
二维码接口只提供明文 api 形态(weapi 形态已 404);其余登录接口走 weapi。
"""
from __future__ import annotations
import hashlib
import re
from typing import Any
from app.http.api_error import ApiError
from app.sdk.auth_store import get_wy_cookie, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
from app.sdk.platforms.wy.utils.crypto import weapi
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
async def _request_weapi(path: str, data: Any, cookie: str | None = None) -> tuple[Any, list[str]]:
ck = get_wy_cookie() if cookie is None else cookie
headers = {
"User-Agent": UA,
"origin": "https://music.163.com",
"Referer": "https://music.163.com/",
}
if ck:
headers["cookie"] = ck
resp = await http_fetch(
f"https://music.163.com/weapi{path}",
method="POST",
headers=headers,
form=weapi(data),
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
async def _request_api(path: str, data: Any) -> tuple[Any, list[str]]:
"""明文 api 请求(二维码接口只提供该形态)。"""
resp = await http_fetch(
f"https://music.163.com{path}",
method="POST",
headers={"User-Agent": UA, "origin": "https://music.163.com", "Referer": "https://music.163.com/"},
form=data,
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError("网易请求失败")
return resp.body, resp.set_cookies
def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
"""从 Set-Cookie 数组提取登录 cookie 串与 MUSIC_U。"""
joined = "; ".join(part for part in (item.split(";")[0].strip() for item in set_cookie) if part)
match = re.search(r"MUSIC_U=([^;]+)", joined)
return joined, match.group(1) if match else ""
async def _fetch_profile() -> dict[str, Any]:
"""登录成功后拉取账号资料。"""
body, _ = await _request_weapi("/api/w/nuser/account/get", {})
body = as_dict(body)
account = as_dict(body.get("account"))
profile = as_dict(body.get("profile"))
return {
"userid": account.get("id"),
"nickname": profile.get("nickname"),
"avatar": profile.get("avatarUrl"),
"vipType": profile.get("vipType"),
}
async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
"""应用登录 cookie(成功后调用)。"""
cookie, music_u = _extract_login_cookie(set_cookie)
if not music_u:
raise RuntimeError("登录响应中缺少 MUSIC_U")
profile = await _fetch_profile()
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
return profile
async def qr_key() -> dict[str, Any]:
"""创建扫码登录 key 与二维码内容。"""
body, _ = await _request_api("/api/login/qrcode/unikey", {"type": 3})
body = as_dict(body)
if body.get("code") != 200 or not body.get("unikey"):
raise RuntimeError("获取二维码 key 失败")
key = str(body["unikey"])
qrurl = f"https://music.163.com/login?codekey={key}"
qrimg = ""
try:
qrimg = to_data_url(qrurl)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {"key": key, "qrurl": qrurl, "qrimg": qrimg}
async def qr_check(key: str) -> dict[str, Any]:
"""轮询扫码状态:800 过期 / 801 等待 / 802 已扫码 / 803 已授权(完成登录)。"""
body, set_cookie = await _request_api("/api/login/qrcode/client/login", {"key": key, "type": 3})
body = as_dict(body)
code = body.get("code")
if code == 803:
profile = await _apply_login(set_cookie)
return {"code": 803, "message": "登录成功", "profile": profile}
messages: dict[Any, str] = {800: "二维码已过期", 801: "等待扫码", 802: "已扫码,待确认"}
message = messages.get(code) or body.get("message") or "未知状态"
return {"code": code, "message": message}
async def cellphone_login(
phone: str, password: str | None = None, captcha: str | None = None, countrycode: str = "86"
) -> dict[str, Any]:
"""手机号登录(密码明文传入,本地 MD5 后提交;或直接传验证码 captcha)。"""
data: dict[str, Any] = {
"type": "1",
"https": "true",
"phone": phone,
"countrycode": countrycode,
"remember": "true",
}
if captcha:
data["captcha"] = captcha
else:
data["password"] = hashlib.md5(str(password or "").encode()).hexdigest()
body, set_cookie = await _request_weapi("/api/w/login/cellphone", data)
body = as_dict(body)
if body.get("code") != 200:
raise ApiError(401, str(body.get("message") or body.get("msg") or "登录失败"))
return await _apply_login(set_cookie)
async def login_by_cookie(cookie_input: str) -> dict[str, Any]:
"""手动绑定 cookie(须含 MUSIC_U);绑定前用 account/get 实测校验。"""
cookie = str(cookie_input or "").strip()
match = re.search(r"MUSIC_U=([^;\s]+)", cookie)
if not match:
raise ApiError(422, "cookie 需包含 MUSIC_U 字段")
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
body = as_dict(body)
account = as_dict(body.get("account"))
if not account.get("id"):
raise ApiError(401, "cookie 无效或已过期(MUSIC_U 校验未通过)")
profile_body = as_dict(body.get("profile"))
profile = {
"userid": account.get("id"),
"nickname": profile_body.get("nickname"),
"avatar": profile_body.get("avatarUrl"),
"vipType": profile_body.get("vipType"),
}
set_credentials("wy", {"cookie": cookie, "musicU": match.group(1), **profile})
return profile
async def status() -> dict[str, Any] | None:
"""当前账号资料(未登录返回 None)。"""
if not get_wy_cookie():
return None
return await _fetch_profile()
+130
View File
@@ -0,0 +1,130 @@
"""登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)。
结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + 原子替换」,
避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
"""
from __future__ import annotations
import json
import os
import threading
import time
from datetime import UTC
from typing import Any
from app.config import data_dir
_lock = threading.Lock()
_cache: dict[str, Any] = {}
_loaded = False
def _data_file():
return data_dir() / "auth.json"
def _load() -> dict[str, Any]:
global _cache, _loaded
if _loaded:
return _cache
path = _data_file()
if not path.exists():
_cache = {}
_loaded = True
return _cache
try:
_cache = json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError) as err:
# 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
# 否则下一次 set_credentials 就会用空表覆盖掉本可恢复的文件。
backup = path.with_name(f"{path.name}.corrupt-{int(time.time() * 1000)}")
try:
path.rename(backup)
print(f"[auth/store] {path} 解析失败,已备份为 {backup},本次以空凭据启动: {err}")
except OSError:
print(f"[auth/store] {path} 解析失败且备份未成功,本次以空凭据启动: {err}")
_cache = {}
_loaded = True
return _cache
def _save(data: dict[str, Any]) -> None:
global _cache, _loaded
_cache = data
_loaded = True
path = _data_file()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
try:
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(json.dumps(data, ensure_ascii=False, indent=2))
os.replace(tmp, path)
except OSError:
try:
tmp.unlink()
except OSError:
pass
raise
def get_credentials(platform: str) -> dict[str, Any] | None:
with _lock:
return _load().get(platform)
def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
with _lock:
data = _load()
merged = {**data.get(platform, {}), **credentials, "updatedAt": _iso_now()}
data[platform] = merged
_save(data)
def clear_credentials(platform: str) -> None:
with _lock:
data = _load()
data.pop(platform, None)
_save(data)
def _iso_now() -> str:
from datetime import datetime
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
# ---- 各平台便捷读法(未登录返回空值,与原文一致) ----
def get_wy_cookie() -> str:
cred = get_credentials("wy") or {}
return str(cred.get("cookie") or "")
def get_kg_auth() -> dict[str, Any] | None:
cred = get_credentials("kg") or {}
if not cred.get("token"):
return None
return {"token": cred["token"], "userid": cred.get("userid") or 0}
def get_tx_auth() -> dict[str, Any] | None:
import re
cred = get_credentials("tx") or {}
cookie = str(cred.get("cookie") or "")
if not cookie:
return None
match = re.search(r"uin=(\d+)", cookie)
return {"cookie": cookie, "uin": match.group(1) if match else "0"}
def get_cookie(platform: str) -> str:
"""通用 cookie 读取(xm/me/lr/mg/bd/joox/apple/5sing 等)。"""
cred = get_credentials(platform) or {}
return str(cred.get("cookie") or "").strip()
+1
View File
@@ -0,0 +1 @@
"""共享基础设施(请求层、工具函数)。"""
+36
View File
@@ -0,0 +1,36 @@
"""二维码生成 —— 把登录 URL 渲染成 data URL,供 /login/* 的 qrimg 字段直接展示。
对应原 TS 里 `import qrcode from 'qrcode'` 的 `toDataURL` 用法(bili 扫码登录)。
用 Pillow 后端输出 PNG,再 base64 成 data URL,前端 <img src> 可直接用。
"""
from __future__ import annotations
import base64
import io
import qrcode as _qrcode
from qrcode.constants import ERROR_CORRECT_M
def to_data_url(text: str, *, box_size: int = 6, border: int = 2) -> str:
"""生成 `data:image/png;base64,...`。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def to_png_bytes(text: str, *, box_size: int = 6, border: int = 2) -> bytes:
"""生成 PNG 原始字节(需要直接返回图片响应时用)。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
return buffer.getvalue()
+224
View File
@@ -0,0 +1,224 @@
"""网络请求层。
对应原 sdk/common/request.ts,但把 Node 的 `{ promise, cancelHttp }` 句柄收敛为
原生 `async def` —— 取消语义由调用方用 `asyncio.wait_for` / task 取消表达。
保持与原文一致的行为细节:
* 不跟随重定向(Node http 默认行为;需要跟随的调用方自行处理 Location)
* 不保留 cookie(每次请求独立;各平台自己管理 Cookie 头,签名依赖这一点)
* body:dict/list 自动 JSON 序列化,str/bytes 原样发送;form 走 urlencoded
* 未显式给 content-type 时按 body 形式补 application/json / x-www-form-urlencoded
* 响应体:无论 content-type 都先尝试 JSON 解析,失败返回文本
* 64MB 响应上限
* 错误文案与原文一致(超时 / 无法连接到服务器 / 请求失败)
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import aiohttp
MAX_BUFFER_SIZE = 64 * 1024 * 1024
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36"
),
}
MSG_CANCEL = "请求已取消"
MSG_FAIL = "请求失败"
MSG_TIMEOUT = "请求超时"
MSG_NOT_CONNECT = "无法连接到服务器"
@dataclass
class HttpResponse:
status_code: int
headers: dict[str, str] = field(default_factory=dict)
body: Any = None
raw: bytes = b""
#: 原始多值 Set-Cookie(headers 折叠同名头后只留最后一个,登录链路必须按数组读)
set_cookies: list[str] = field(default_factory=list)
@property
def statusCode(self) -> int: # 兼容原文命名
return self.status_code
def header(self, name: str, default: str = "") -> str:
return self.headers.get(name.lower(), default)
def json(self) -> Any:
"""把 body 当 JSON 取;body 已是结构时原样返回。"""
return self.body
_session: aiohttp.ClientSession | None = None
_session_loop: asyncio.AbstractEventLoop | None = None
def _get_session() -> aiohttp.ClientSession:
"""进程级连接池。禁用 cookie jar 与自动重定向,语义对齐 Node http。
trust_env=False:Node 的 http/https 不读系统代理,而 aiohttp 在 Windows 上会通过
注册表拿到「Internet 选项」里的代理(本机是 127.0.0.1:7890)。走该代理会让部分域名
(如 xm 的 hdaa.shuzilm.cn)连不上,且所有请求的出口 IP/风控画像与 TS 版不一致,
因此这里显式直连,与 Node 行为保持一致。
"""
global _session, _session_loop
loop = asyncio.get_running_loop()
if _session is None or _session.closed or _session_loop is not loop:
_session = aiohttp.ClientSession(
cookie_jar=aiohttp.DummyCookieJar(),
auto_decompress=True,
trust_env=False,
connector=aiohttp.TCPConnector(limit=200, ttl_dns_cache=300),
)
_session_loop = loop
return _session
async def close_session() -> None:
global _session
if _session is not None and not _session.closed:
await _session.close()
_session = None
def _parse_body(raw: bytes) -> Any:
if not raw:
return None
text = raw.decode("utf-8", errors="replace")
try:
return json.loads(text)
except (ValueError, TypeError):
return text
def _build_body(options: Mapping[str, Any]) -> tuple[bytes | None, bool]:
form = options.get("form")
if form:
from urllib.parse import urlencode
pairs: list[tuple[str, str]] = []
for key, value in form.items():
if value is None:
continue
# 对象值对齐 URLSearchParams 的 JSON.stringify(紧凑、无空格)
pairs.append(
(
key,
json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, (dict, list))
else str(value),
)
)
return urlencode(pairs).encode(), True
body = options.get("body")
if body is None:
return None, False
if isinstance(body, bytes):
return body, False
if isinstance(body, str):
return body.encode(), False
# 必须与 JS 的 JSON.stringify 逐字节一致(紧凑分隔符):
# 部分平台(kg 网关)的 signature 就是按紧凑 JSON 计算的,空格会导致验签失败。
return json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode(), False
def _flatten_headers(headers: Mapping[str, Any]) -> dict[str, str]:
return {str(k): str(v) for k, v in headers.items()}
async def http_fetch(url: str, **options: Any) -> HttpResponse:
"""发起 HTTP 请求并返回完整响应。
options: method / headers / body / form / timeout(毫秒) / lookup / family
"""
method = str(options.get("method") or "GET").upper()
payload, is_form = _build_body(options)
headers = {**DEFAULT_HEADERS, **_flatten_headers(options.get("headers") or {})}
lowered = {k.lower() for k in headers}
if payload is not None:
if "content-type" not in lowered:
headers["content-type"] = "application/x-www-form-urlencoded" if is_form else "application/json"
if "content-length" not in lowered:
headers["content-length"] = str(len(payload))
timeout_ms = options.get("timeout")
timeout = aiohttp.ClientTimeout(total=(timeout_ms / 1000) if timeout_ms else 60)
session = _get_session()
try:
async with session.request(
method,
url,
headers=headers,
data=payload,
timeout=timeout,
allow_redirects=False,
) as resp:
chunks: list[bytes] = []
size = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
size += len(chunk)
if size > MAX_BUFFER_SIZE:
raise RuntimeError("Response too large")
chunks.append(chunk)
raw = b"".join(chunks)
return HttpResponse(
status_code=resp.status,
headers={k.lower(): v for k, v in resp.headers.items()},
body=_parse_body(raw),
raw=raw,
set_cookies=list(resp.headers.getall("set-cookie", [])),
)
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorDNSError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientConnectionError as err:
if isinstance(err, aiohttp.ServerDisconnectedError):
raise ConnectionError(MSG_FAIL) from err
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_probe(url: str, *, headers: Mapping[str, Any] | None = None, timeout_ms: int = 8000) -> tuple[int, str]:
"""只读响应头的探测请求:返回 ``(status, location)``,**不消费 body**。
取流 CDN 探测专用(bili):CDN 忽略 Range 时会尝试把整段音频推下来,
等 http_fetch 读完 body 就等于把文件下载完(点歌卡死)。拿到响应头即退出上下文,
aiohttp 随即中止并关闭连接。不跟随重定向,调用方用 location 自行跟跳(对应 Node 的 manual)。
"""
timeout_obj = aiohttp.ClientTimeout(total=timeout_ms / 1000)
session = _get_session()
try:
async with session.request(
"GET",
url,
headers={**DEFAULT_HEADERS, **_flatten_headers(headers or {})},
timeout=timeout_obj,
allow_redirects=False,
) as resp:
return resp.status, resp.headers.get("location", "")
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_get_json(url: str, **options: Any) -> Any:
"""GET 并返回解析后的 body。"""
options.setdefault("method", "get")
resp = await http_fetch(url, **options)
return resp.body
+158
View File
@@ -0,0 +1,158 @@
"""通用工具函数(对应原 sdk/common/utils.ts 与 sdk/utils.ts)。"""
from __future__ import annotations
import hashlib
import math
import re
import time
from collections.abc import Iterable, Mapping
from datetime import datetime
from typing import Any
def to_md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def as_dict(value: Any) -> dict[str, Any]:
"""上游 ``x?.field ?? {}`` 的安全取法:非 dict 一律当空 dict。"""
return value if isinstance(value, dict) else {}
def as_list(value: Any) -> list[Any]:
"""上游 ``x ?? []`` 的安全取法:非 list 一律当空列表。"""
return value if isinstance(value, list) else []
def size_formate(size: float | int | None) -> str:
"""将字节数格式化为可读字符串。"""
if not size:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
number = math.floor(math.log(size) / math.log(1024))
number = min(number, len(units) - 1)
return f"{size / math.pow(1024, number):.2f} {units[number]}"
def format_play_time(seconds: float | int | str | None) -> str:
"""秒数 → mm:ss;0 返回 --/--。"""
try:
value = float(seconds) # type: ignore[arg-type]
except (TypeError, ValueError):
return "--/--"
if math.isnan(value) or value < 0:
value = 0
minutes = int(value // 60)
secs = int(value % 60)
if minutes == 0 and secs == 0:
return "--/--"
return f"{minutes:02d}:{secs:02d}"
def date_format(value: Any, fmt: str = "Y-M-D h:m:s") -> str:
"""时间对象/时间戳/字符串 → 日期字符串(Y/M/D/h/m/s 占位)。"""
if not value:
return ""
if isinstance(value, datetime):
date = value
elif isinstance(value, (int, float)):
date = datetime.fromtimestamp(value / 1000 if value > 1e11 else value)
elif isinstance(value, str):
raw = value
if "T" not in raw:
raw = raw.split(".")[0].replace("-", "/")
parsed: datetime | None = None
for pattern in ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
try:
parsed = datetime.strptime(raw[: len(pattern) + 10], pattern)
break
except ValueError:
continue
if parsed is None:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return ""
date = parsed
else:
return ""
return (
fmt.replace("Y", str(date.year))
.replace("M", f"{date.month:02d}")
.replace("D", f"{date.day:02d}")
.replace("h", f"{date.hour:02d}")
.replace("m", f"{date.minute:02d}")
.replace("s", f"{date.second:02d}")
)
def date_format2(timestamp_ms: float | int) -> str:
"""相对时间格式化(用于评论时间)。"""
differ = int((time.time() * 1000 - float(timestamp_ms)) / 1000)
if differ < 60:
return f"{differ}秒前"
if differ < 3600:
return f"{differ // 60}分钟前"
if differ < 86400:
return f"{differ // 3600}小时前"
if differ < 2592000:
return f"{differ // 86400}天前"
return date_format(timestamp_ms)
def format_play_count(num: float | int | None) -> str:
if num is None:
return "0"
if num > 100000000:
return f"{int(num / 10000000) / 10}亿"
if num > 10000:
return f"{int(num / 1000) / 10}万"
return str(num)
_ENTITY_MAP = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
"&nbsp;": " ",
}
def decode_name(text: Any = "") -> str:
"""解码 HTML 实体。"""
if not text:
return ""
if not isinstance(text, str):
text = str(text)
for entity, char in _ENTITY_MAP.items():
text = text.replace(entity, char)
return re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text)
def format_singer_name(
singers: Iterable[Mapping[str, Any]] | str | None,
name_key: str = "name",
join: str = "、",
) -> str:
"""歌手数组或字符串 → 展示名(数组按 name_key 提取后 join)。"""
if isinstance(singers, (list, tuple)):
names = [str(item[name_key]) for item in singers if isinstance(item, Mapping) and item.get(name_key)]
return decode_name(join.join(names))
return decode_name(str(singers or ""))
def pick(data: Any, *keys: str, default: Any = None) -> Any:
"""沿键路径取值(dict 缺失返回 default),迁移时统一空值访问。"""
cur = data
for key in keys:
if isinstance(cur, Mapping):
cur = cur.get(key)
else:
return default
if cur is None:
return default
return cur
+4
View File
@@ -0,0 +1,4 @@
"""平台包集合。每个子包导出 `MODULE: Platform`。
目录名与平台 id 的差异:5sing → fivesing(Python 标识符不能以数字开头)。
"""
+110
View File
@@ -0,0 +1,110 @@
"""平台模块契约 —— 各平台包导出 `MODULE = Platform(...)`。
对应原 TS 的 `sdk/<platform>/index.ts` default export 对象:
const kw = { musicSearch, leaderboard, songList, hotSearch, comment, album, playlist, parse,
getMusicUrl, getLyric, getPic, handleMusicInfo, getMusicInfo, ... }
Python 侧用 `Platform` 容器承载子模块(属性名 snake_case),能力位缺省为 None 表示不支持,
路由层据此返回 404(与 /sources 的能力位同源)。
子模块协议:
music_search.search(keyword, page=1, limit=20, retry_num=0) -> {"list": [...], ...}
leaderboard.get_boards() -> {"list": [{"id", "name", "bangid"}]}
leaderboard.get_list(bangid, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.get_list(sort_id, tag_id, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.sort_list -> [{"id", "name"}](可选,路由取首项作默认排序)
hot_search.get_list() -> {"list": [str], "source"}
singer.get_info(id) / get_song_list(id, page, limit) / get_album_list(id, page, limit)
album.get_album_detail(id, page, limit) | get_album_list_detail(id, page)
album.search_album(keyword, page, limit)
playlist.get_playlist_detail(id, page, limit) | get_playlist_songs(id)
playlist.search_playlist(keyword, page, limit) | get_categories() | get_category_playlists(id, page, limit)
parse.parse_song(link) / parse_album(link) / parse_playlist(link)
user_playlist.get_list(page, limit)
顶层方法(由 Platform 直接持有):
get_music_url(song_info, quality, retry_num=0) -> {"type", "url"}
get_lyric(song_info) -> {"lyric", "tlyric", "rlyric"?, "yrc"?}
get_pic(song_info) -> str
get_music_info(song_info) -> dict(可选,酷我兜底等场景需要)
handle_music_info(song_info) -> dict(可选,补全歌名/歌手/封面)
get_music_detail_page_url(song_info) -> str(可选)
"""
from __future__ import annotations
from typing import Any
# 能力位名称(/sources 的 capabilities 键 → Platform 属性)
CAPABILITY_ATTRS = {
"search": "music_search",
"url": "get_music_url",
"lyric": "get_lyric",
"pic": "get_pic",
"boards": "leaderboard",
"hotSearch": "hot_search",
"songList": "song_list",
"singer": "singer",
"playlist": "playlist",
"parse": "parse",
"userPlaylist": "user_playlist",
"comment": "comment",
}
class Platform:
"""平台模块容器。只声明属性,不做校验 —— 缺省即不支持。"""
# 平台短码与中文名(/sources 用)
id: str = ""
name: str = ""
music_search: Any = None
leaderboard: Any = None
song_list: Any = None
hot_search: Any = None
comment: Any = None
singer: Any = None
album: Any = None
playlist: Any = None
parse: Any = None
user_playlist: Any = None
get_music_url: Any = None
get_lyric: Any = None
get_pic: Any = None
get_music_info: Any = None
handle_music_info: Any = None
get_music_urls: Any = None
get_music_detail_page_url: Any = None
init: Any = None
def __init__(self, **kwargs: Any) -> None:
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self) -> str:
caps = [key for key in CAPABILITY_ATTRS.values() if getattr(self, key, None) is not None]
return f"<Platform {self.id} caps={','.join(caps)}>"
def capability_map(platform: Platform) -> dict[str, bool]:
"""生成 /sources 的 capabilities 字段(键名沿用原 API 的驼峰写法)。"""
caps: dict[str, bool] = {name: getattr(platform, attr, None) is not None for name, attr in CAPABILITY_ATTRS.items()}
album = platform.album
caps["album"] = album is not None and (
getattr(album, "get_album_detail", None) is not None
or getattr(album, "get_album_list_detail", None) is not None
)
caps["albumSearch"] = album is not None and getattr(album, "search_album", None) is not None
playlist = platform.playlist
caps["playlist"] = playlist is not None and getattr(playlist, "get_playlist_detail", None) is not None
caps["playlistSearch"] = playlist is not None and getattr(playlist, "search_playlist", None) is not None
caps["playlistCategories"] = playlist is not None and getattr(playlist, "get_categories", None) is not None
parse = platform.parse
caps["parse"] = parse is not None and any(
getattr(parse, f"parse_{kind}", None) is not None for kind in ("song", "album", "playlist")
)
caps.pop("comment", None)
return caps
+134
View File
@@ -0,0 +1,134 @@
"""Apple Music 平台模块(对应 sdk/apple/index.ts,上游 music-lib/apple/apple.go 的 Node/TS 移植)。
能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到)/ 封面 /
专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 apis/apple.py)。
凭据:auth_store 的 apple 记录,cookie 形如
``"media-user-token=xxx;storefront=cn;token=xxx"``,也支持 storefront/mediaUserToken/token 直填字段。
未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.apple import album, lyric, music_search, parse, playlist
from app.sdk.platforms.apple.util import (
apple_artwork_url,
apple_song_from_catalog,
apple_web_url,
extract_apple_id,
fetch_apple_song_resource,
get_apple_config,
get_apple_token,
)
def _song_id_of(song_info: dict[str, Any]) -> str:
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
async def _init() -> bool:
"""预热 developer token(失败不阻塞启动,路由调用时会重试)。"""
try:
await get_apple_token()
return True
except Exception: # noqa: BLE001 - token 预热失败不影响后续请求的重试
return False
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.apple import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort)。"""
if song_info.get("img"):
return str(song_info["img"])
song_id = _song_id_of(song_info)
if not song_id:
return None
try:
res = await fetch_apple_song_resource(song_id)
if not res:
return None
return apple_artwork_url(res.get("attributes", {}).get("artwork"), 600) or None
except Exception: # noqa: BLE001 - 封面取不到返回 None
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""歌曲详情(best-effort;等价上游 Parse(id))。"""
song_id = _song_id_of(song_info)
if not song_id:
return None
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
return apple_song_from_catalog(res) if res else None
class _AppleAlbum:
"""契约 album 能力位(含 TS index.ts 里挂的 parseAlbumLink)。"""
get_album_detail = staticmethod(album.get_album_detail)
get_album_songs = staticmethod(album.get_album_songs)
search_album = staticmethod(album.search_album)
parse_album_link = staticmethod(album.parse_album_link)
class _AppleSongList:
"""契约别名 songList:Apple 的歌单分类即 genre curators,供 /song-list/apple 复用。"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "热门", "id": "1526756058"}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, limit: int = 20) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate, page, limit)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
song_id = _song_id_of(song_info)
if not song_id:
return "https://music.apple.com/"
return apple_web_url("song", song_id, get_apple_config()["storefront"])
def status() -> dict[str, Any]:
"""登录状态(media-user-token 存在性判断;无在线校验接口)。"""
cred = get_credentials("apple") or {}
cfg = get_apple_config()
if cfg["mediaUserToken"] or cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="apple",
name="Apple Music",
init=_init,
music_search=music_search,
album=_AppleAlbum,
playlist=playlist,
song_list=_AppleSongList,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+89
View File
@@ -0,0 +1,89 @@
"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。
详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
→ data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_album
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
async def get_album_detail(raw_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(page/limit 在完整曲目上切片,与其它平台 get_album_detail 契约一致)。"""
album_id = extract_apple_id(str(raw_id or ""), "album")
if not album_id:
raise RuntimeError(f"invalid apple music album id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/albums/{album_id}", {"extend": "extendedAssetUrls"})
)
data = as_list(body.get("data"))
album = as_dict(data[0]) if data else {}
if not album:
raise RuntimeError(f"apple album not found: {album_id}")
tracks = await fetch_all_track_resources(as_dict(album.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track, album_id) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 50))
summary = apple_album_to_playlist(album)
# 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": album_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
"releaseDate": summary["extra"]["release_date"],
"recordLabel": summary["extra"]["record_label"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只返回曲目数组。"""
detail = await get_album_detail(album_id, 1, 100000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页)。"""
detail = await get_album_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = ["get_album_detail", "get_album_songs", "parse_album_link", "search_album"]
+45
View File
@@ -0,0 +1,45 @@
"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 apple.go GetLyrics)。
通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
→ data[0].relationships.lyrics.data[*].attributes.ttml
上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
(即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词需要 media-user-token(订阅态);
本实现保持上游行为:拿不到就返回 ``{lyric: '', tlyric: ''}``,不抛错。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"include": "lyrics", "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
first = as_dict(data[0]) if data else {}
relationships = as_dict(first.get("relationships"))
lyrics = as_list(as_dict(relationships.get("lyrics")).get("data"))
for rel in lyrics:
text = str(as_dict(as_dict(rel).get("attributes")).get("ttml") or "")
if text:
return {"lyric": text, "tlyric": ""}
return {"lyric": "", "tlyric": ""}
async def get_lyrics_text(song_id: str) -> str:
"""非取消句柄版:直接返回歌词文本(内部/测试用)。"""
result = await get_lyric({"songmid": song_id})
return result.get("lyric") or ""
+90
View File
@@ -0,0 +1,90 @@
"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。
通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.py)
单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
get_apple_config,
)
#: amp-api search 单页硬上限
SEARCH_PAGE_MAX = 25
async def _catalog_search(keyword: str, types: str, page: int, limit: int) -> dict[str, Any]:
word = str(keyword or "").strip()
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 100)
if not word:
return {"body": None, "page": p, "limit": size}
cfg = get_apple_config()
body = await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/search",
{
"term": word,
"types": types,
"limit": min(size, SEARCH_PAGE_MAX),
"offset": (p - 1) * size,
},
)
return {"body": body, "page": p, "limit": size}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,amp-api 无重试语义
result = await _catalog_search(keyword, "songs", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
bucket = as_dict(results.get("songs"))
songs = [apple_song_from_catalog(item) for item in as_list(bucket.get("data"))]
has_next = bool(bucket.get("next"))
return {
"list": songs,
"allPage": p + 1 if has_next else p,
"total": (p - 1) * size + len(songs),
"limit": size,
"source": "apple",
}
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchAlbum:搜索专辑(返回歌单摘要形状)。"""
result = await _catalog_search(keyword, "albums", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("albums")).get("data"))
songs = [apple_album_to_playlist(item) for item in items]
return {
"list": songs,
"total": (p - 1) * size + len(songs),
"page": p,
"limit": size,
"source": "apple",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist:搜索歌单。"""
result = await _catalog_search(keyword, "playlists", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("playlists")).get("data"))
playlists = [apple_playlist_to_playlist(item) for item in items]
return {
"list": playlists,
"total": (p - 1) * size + len(playlists),
"page": p,
"limit": size,
"source": "apple",
}
+34
View File
@@ -0,0 +1,34 @@
"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 apple.go Parse / ParseAlbum / ParsePlaylist)。
song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.apple.album import parse_album_link
from app.sdk.platforms.apple.playlist import parse_playlist_link
from app.sdk.platforms.apple.util import apple_song_from_catalog, extract_apple_id, fetch_apple_song_resource
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链)。"""
song_id = extract_apple_id(str(link or ""), "song")
if not song_id:
raise RuntimeError(f"invalid apple music song link: {link}")
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
if not res:
raise RuntimeError(f"apple song not found: {song_id}")
return apple_song_from_catalog(res)
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+177
View File
@@ -0,0 +1,177 @@
"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。
详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
曲目超过一页时沿 relationships.tracks.next 全量拉取(带 20 页上限防死循环)。
分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
链接:extract_apple_id(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_playlist
from app.sdk.platforms.apple.util import (
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
#: 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators)
APPLE_CURATOR_CATEGORIES: list[dict[str, str]] = [
{"id": "1526756058", "name": "热门"},
{"id": "1479949880", "name": "C-Pop"},
{"id": "1019400042", "name": "国语流行"},
{"id": "1019398918", "name": "粤语流行"},
{"id": "1019399540", "name": "国际流行"},
{"id": "1019399551", "name": "K-Pop"},
{"id": "1019399547", "name": "J-Pop"},
{"id": "989061415", "name": "嘻哈/说唱"},
{"id": "1019400044", "name": "R&B"},
{"id": "1019400046", "name": "摇滚"},
{"id": "1019397973", "name": "另类音乐"},
{"id": "976439535", "name": "舞曲"},
{"id": "976439536", "name": "电子"},
{"id": "976439541", "name": "独立音乐"},
{"id": "1019399549", "name": "爵士乐"},
{"id": "1019398924", "name": "古典音乐"},
{"id": "976439528", "name": "蓝调"},
{"id": "976439534", "name": "乡村音乐"},
{"id": "1531542847", "name": "拉丁音乐"},
{"id": "988656348", "name": "非洲音乐"},
{"id": "976439543", "name": "金属乐"},
{"id": "976439550", "name": "朋克乐"},
{"id": "1019400049", "name": "不插电"},
{"id": "1231181168", "name": "影视原声"},
{"id": "1441811365", "name": "DJ 混音精选"},
{"id": "1532467784", "name": "瞩目之星"},
{"id": "1554938339", "name": "年代之声"},
{"id": "1564180390", "name": "空间音频"},
{"id": "989010186", "name": "亲子"},
]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:静态返回 Apple Music genre curators。"""
return {
"source": "apple",
"list": [
{"id": item["id"], "name": item["name"], "group": "Apple Music 分类", "source": "apple"}
for item in APPLE_CURATOR_CATEGORIES
],
}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐)。"""
cate = str(category_id or "").strip()
if not cate:
raise RuntimeError("apple 分类 id 不能为空")
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 300)
api_max = 25
offset = (p - 1) * size
all_playlists: list[dict[str, Any]] = []
while len(all_playlists) < size:
batch_size = min(api_max, size - len(all_playlists))
try:
body = as_dict(
await apple_amp_get(
f"/v1/catalog/cn/apple-curators/{cate}/playlists",
{"limit": batch_size, "offset": offset, "l": "zh-Hans-CN"},
)
)
except Exception: # 上游:已有数据时返回已取部分
if all_playlists:
break
raise
data = as_list(body.get("data"))
all_playlists.extend(apple_playlist_to_playlist(item) for item in data)
# 没有更多数据或没有下一页
if len(data) < batch_size or not body.get("next"):
break
offset += len(data)
return {"source": "apple", "list": all_playlists[:size], "page": p, "limit": size, "total": len(all_playlists)}
async def get_playlist_detail(raw_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(全量曲目后按 page/limit 切片)。"""
playlist_id = extract_apple_id(str(raw_id or ""), "playlist")
if not playlist_id:
raise RuntimeError(f"invalid apple music playlist id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/playlists/{playlist_id}",
{"limit[tracks]": 300, "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
item = as_dict(data[0]) if data else {}
if not item:
raise RuntimeError(f"apple playlist not found: {playlist_id}")
tracks = await fetch_all_track_resources(as_dict(item.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 100))
summary = apple_playlist_to_playlist(item)
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": playlist_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只返回曲目数组。"""
detail = await get_playlist_detail(playlist_id, 1, 100000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页)。"""
detail = await get_playlist_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = [
"APPLE_CURATOR_CATEGORIES",
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"parse_playlist_link",
"search_playlist",
]
+465
View File
@@ -0,0 +1,465 @@
"""Apple Music 公共工具(对应 sdk/apple/util.ts,上游 music-lib/apple/apple.go 的移植基础层)。
* ``New(cookie)`` → auth_store 的 apple 记录:cookie 支持
``"media-user-token=xxx;storefront=cn;token=xxx"`` 形态。
* ``fetch_apple_token``:从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)。
当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi 开头而非上游正则写的 eyJh,
故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取。
* amp 请求(httpFetch 不自动跟 302,首页跳转用 fetch_follow 手动跟)。
* 模型转换与 ID 提取(字段映射同上游,apple_test.go 的断言逐条保留)。
"""
from __future__ import annotations
import asyncio
import base64
import json
import re
import time
from typing import Any
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
APPLE_HOMEPAGE_URL = "https://music.apple.com"
APPLE_AMP_API_URL = "https://amp-api.music.apple.com"
DEFAULT_STOREFRONT = "us"
APPLE_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
#: JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
TOKEN_CACHE_TTL_MS = 12 * 60 * 60 * 1000
_cached_token = ""
_cached_at = 0.0
#: 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
_bypass_explicit_token = False
_token_lock = asyncio.Lock()
def parse_cookie_parts(cookie: str) -> dict[str, str]:
"""上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白。"""
result: dict[str, str] = {}
for part in str(cookie or "").split(";"):
trimmed = part.strip()
idx = trimmed.find("=")
if idx > 0:
result[trimmed[:idx].strip()] = trimmed[idx + 1 :].strip()
return result
def get_apple_config() -> dict[str, str]:
"""读取 apple 平台运行时配置(对应上游 New(cookie))。"""
cred = get_credentials("apple") or {}
parts = parse_cookie_parts(str(cred.get("cookie") or ""))
storefront = str(cred.get("storefront") or parts.get("storefront") or DEFAULT_STOREFRONT).strip()
return {
"storefront": storefront or DEFAULT_STOREFRONT,
"mediaUserToken": str(cred.get("mediaUserToken") or parts.get("media-user-token") or "").strip(),
"token": str(cred.get("token") or parts.get("token") or "").strip(),
}
def decode_apple_token_payload(token: str) -> dict[str, Any]:
"""解码 JWT payload(Apple token 为 ES256 JWT)。"""
try:
part = str(token or "").split(".")[1]
padded = part.replace("-", "+").replace("_", "/")
padded += "=" * (-len(padded) % 4)
data = json.loads(base64.b64decode(padded).decode("utf-8"))
return data if isinstance(data, dict) else {}
except (IndexError, ValueError, TypeError):
return {}
def is_apple_token_expired(token: str) -> bool:
try:
exp = float(decode_apple_token_payload(token).get("exp") or 0)
except (TypeError, ValueError):
exp = 0
return exp > 0 and exp * 1000 < time.time() * 1000 + 60 * 1000
def extract_apple_tokens(js: str) -> list[str]:
"""从 index JS 中提取所有 JWT 形态的候选 token(含引号前缀的主匹配 + 无前缀回退)。"""
pattern = r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
found: list[str] = []
found.extend(m.group(1) for m in re.finditer(rf"""(?:=["']){pattern}""", str(js or "")))
if not found:
found.extend(m.group(1) for m in re.finditer(pattern, str(js or "")))
# 去重且保持顺序
return list(dict.fromkeys(found))
def pick_apple_token(js: str) -> str:
"""选择可用 token:优先 issuer=AMPWebPlay,其次第一个未过期;全部过期视为抓取失败。"""
tokens = extract_apple_tokens(js)
if not tokens:
return ""
alive = [token for token in tokens if not is_apple_token_expired(token)]
if not alive:
return ""
preferred = next(
(token for token in alive if str(decode_apple_token_payload(token).get("iss") or "") == "AMPWebPlay"), None
)
return preferred or alive[0] or ""
async def fetch_follow(url: str, headers: dict[str, str] | None = None, max_hops: int = 5) -> HttpResponse:
"""手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳)。"""
current = str(url)
response = None
for _hop in range(max_hops):
response = await http_fetch(current, headers=headers or {}, timeout=20_000)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
current = urljoin(current, location)
continue
return response
raise RuntimeError("apple: too many redirects")
def _response_text(response: HttpResponse) -> str:
if isinstance(response.body, str):
return response.body
if response.raw:
return response.raw.decode("utf-8", errors="replace")
return str(response.body or "")
async def fetch_apple_token() -> str:
"""上游 fetchAppleToken:首页 → index-legacy JS → bearer token。"""
home = await fetch_follow(APPLE_HOMEPAGE_URL, {"User-Agent": APPLE_UA})
if home.status_code != 200:
raise RuntimeError(f"apple: fetch homepage error: HTTP {home.status_code}")
html = _response_text(home)
# 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
match = re.search(r"/(assets/index-legacy[~-][^/\"']+\.js)", html)
if not match:
match = re.search(r"/(assets/index[~-][^/\"']+\.js)", html)
if not match:
raise RuntimeError("apple: index.js URI not found")
js_res = await fetch_follow(f"{APPLE_HOMEPAGE_URL}/{match.group(1)}", {"User-Agent": APPLE_UA})
if js_res.status_code != 200:
raise RuntimeError(f"apple: fetch index.js error: HTTP {js_res.status_code}")
token = pick_apple_token(_response_text(js_res))
if not token:
raise RuntimeError("apple: token not found in index.js")
return token
async def get_apple_token(force: bool = False) -> str:
"""获取 developer token(缓存 + JWT exp 校验 + 并发去重)。"""
global _cached_token, _cached_at
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
async with _token_lock:
# 双检:并发调用者拿到锁后直接复用前一次抓取结果
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
token = await fetch_apple_token()
_cached_token = token
_cached_at = time.time() * 1000
return token
def invalidate_apple_token() -> None:
"""清除缓存 token(401/403 时调用,下次请求会回源重取)。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = True
def reset_apple_token_state() -> None:
"""仅测试/调试用:重置内存态。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = False
def apple_amp_headers(token: str) -> dict[str, str]:
cfg = get_apple_config()
headers = {
"Authorization": f"Bearer {token}",
"Origin": APPLE_HOMEPAGE_URL,
"Referer": f"{APPLE_HOMEPAGE_URL}/",
"User-Agent": APPLE_UA,
"Accept": "application/json",
}
# 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
if cfg["mediaUserToken"]:
headers["Cookie"] = f"media-user-token={cfg['mediaUserToken']}"
return headers
def build_apple_amp_url(uri: str, params: dict[str, Any] | None = None) -> str:
"""拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL。"""
raw = str(uri or "")
if re.match(r"^https?://", raw, re.IGNORECASE):
base = raw
elif raw.startswith("/"):
base = APPLE_AMP_API_URL + raw
else:
base = f"{APPLE_AMP_API_URL}/{raw}"
pairs = [(key, str(value)) for key, value in (params or {}).items() if value is not None and value != ""]
if not pairs:
return base
query = urlencode(pairs)
return base + ("&" if "?" in base else "?") + query
def _amp_error_detail(response: HttpResponse) -> str:
body = response.body if isinstance(response.body, dict) else {}
errors = as_list(body.get("errors"))
first = as_dict(errors[0]) if errors else {}
return str(first.get("detail") or first.get("title") or "")
async def apple_amp_get_tracked(uri: str, params: dict[str, Any] | None = None) -> Any:
"""amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)。"""
url = build_apple_amp_url(uri, params)
async def attempt() -> HttpResponse:
token = await get_apple_token()
return await http_fetch(url, headers=apple_amp_headers(token), timeout=20_000)
response = await attempt()
if response.status_code in (401, 403):
invalidate_apple_token()
response = await attempt()
if not (200 <= response.status_code < 300):
detail = _amp_error_detail(response)
raise RuntimeError(f"apple amp-api HTTP {response.status_code}{f': {detail}' if detail else ''}")
return response.body
async def apple_amp_get(uri: str, params: dict[str, Any] | None = None) -> Any:
return await apple_amp_get_tracked(uri, params)
#: 拉取 relationships.tracks 全部分页时的页数上限(防死循环)
MAX_TRACK_PAGES = 20
async def fetch_all_track_resources(tracks: Any) -> list[Any]:
"""拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources)。"""
tracks = as_dict(tracks)
all_tracks: list[Any] = list(as_list(tracks.get("data")))
next_uri = str(tracks.get("next") or "").strip()
page = 0
while next_uri and page < MAX_TRACK_PAGES:
body = as_dict(await apple_amp_get_tracked(next_uri))
all_tracks.extend(as_list(body.get("data")))
next_uri = str(body.get("next") or "").strip()
page += 1
return all_tracks
def apple_artwork_url(artwork: Any, size: int = 600) -> str:
"""上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位。"""
artwork = as_dict(artwork)
url = str(artwork.get("url") or "")
if not url:
return ""
return url.replace("{w}", str(size)).replace("{h}", str(size))
def _round_number(value: Any) -> int:
"""JS Math.round 的等价实现(.5 向上取整)。"""
import math
try:
number = float(value or 0)
except (TypeError, ValueError):
number = 0.0
return math.floor(number + 0.5)
def apple_song_from_catalog(res: Any, default_album_id: str = "") -> dict[str, Any]:
"""上游 appleSongFromCatalog → 契约 Song 形状。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
previews = as_list(attr.get("previews"))
preview_url = str(as_dict(previews[0]).get("url") or "") if previews else ""
duration_sec = _round_number(float(attr.get("durationInMillis") or 0) / 1000)
return {
"songmid": str(res.get("id") or ""),
"name": attr.get("name") or "",
"singer": attr.get("artistName") or "",
"albumId": str(attr.get("albumId") or default_album_id or ""),
"albumName": attr.get("albumName") or "",
"interval": format_play_time(duration_sec),
"img": apple_artwork_url(attr.get("artwork"), 600) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "apple",
"link": attr.get("url") or "", # music.apple.com 网页地址
"previewUrl": preview_url, # 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
"duration": duration_sec,
"isrc": attr.get("isrc") or "",
}
def apple_album_to_playlist(res: Any) -> dict[str, Any]:
"""上游 appleAlbumToPlaylist → 契约歌单/专辑摘要。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
notes = as_dict(attr.get("editorialNotes"))
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("artistName") or "",
"description": notes.get("short") or "",
"link": attr.get("url") or "",
"source": "apple",
"extra": {
"release_date": attr.get("releaseDate") or "",
"record_label": attr.get("recordLabel") or "",
},
}
def apple_playlist_to_playlist(res: Any) -> dict[str, Any]:
"""上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游)。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
description = as_dict(attr.get("description"))
notes = as_dict(attr.get("editorialNotes"))
desc = description.get("short") or description.get("standard") or notes.get("short") or notes.get("standard") or ""
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("curatorName") or "",
"description": desc,
"link": attr.get("url") or "",
"source": "apple",
}
def apple_web_url(kind: str, media_id: str, storefront: str = DEFAULT_STOREFRONT) -> str:
sf = str(storefront or DEFAULT_STOREFRONT)
return f"{APPLE_HOMEPAGE_URL}/{sf}/{kind}/{media_id}"
def is_apple_raw_id(value: str, media_type: str) -> bool:
"""上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字)。"""
if re.search(r"[/?#]", value) or "://" in value:
return False
if media_type == "playlist":
if value.startswith("pl."):
return re.fullmatch(r"[a-zA-Z0-9._-]+", value) is not None
return re.fullmatch(r"[a-zA-Z0-9_-]+", value) is not None
return re.fullmatch(r"\d+", value) is not None
def extract_apple_id_from_path(path: str, media_type: str) -> str:
"""上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id>。"""
trimmed = re.sub(r"^/+|/+$", "", str(path or ""))
if not trimmed:
return ""
segments = trimmed.split("/")
for index, segment in enumerate(segments):
if segment != media_type and segment != f"{media_type}s":
continue
if index + 2 < len(segments):
return segments[index + 2]
if index + 1 < len(segments):
return segments[index + 1]
return ""
def extract_apple_id(link: str, media_type: str) -> str:
"""上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底。"""
raw = str(link or "").strip()
if not raw:
return ""
if is_apple_raw_id(raw, media_type):
return raw
path = ""
query = ""
try:
parts = urlsplit(raw)
if parts.scheme or parts.netloc:
path = parts.path or ""
query = f"?{parts.query}" if parts.query else ""
except ValueError:
path, query = "", ""
if not path:
idx = raw.find("?")
path = raw[:idx] if idx >= 0 else raw
query = raw[idx:] if idx >= 0 else ""
if path:
if media_type == "song":
values = parse_qs(query.lstrip("?"), keep_blank_values=True).get("i")
song_id = values[0].strip() if values else ""
if song_id:
return song_id
from_path = extract_apple_id_from_path(path, media_type)
if from_path:
return from_path
patterns = [
rf"{media_type}/[^/]+/(\d+)",
rf"{media_type}s?/[^/]+/[^/]+/(\d+)",
r"/(\d+)$",
r"[?&]i=(\d+)",
]
if media_type == "playlist":
patterns.extend(
[
r"playlists?/[^/?#]+/([a-zA-Z0-9._-]+)",
r"playlists?/([a-zA-Z0-9._-]+)(?:[?#]|$)",
]
)
for pattern in patterns:
match = re.search(pattern, raw)
if match:
return match.group(1)
return ""
async def fetch_apple_song_resource(song_id: str, include: str = "") -> Any:
"""歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>)。"""
cfg = get_apple_config()
params: dict[str, Any] = {"extend": "extendedAssetUrls"}
if include:
params["include"] = include
body = as_dict(await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/songs/{song_id}", params))
data = as_list(body.get("data"))
return data[0] if data else None
def quote_path_segment(value: str) -> str:
"""路径段转义(ID 基本是字母数字,保留以免未来出现特殊字符)。"""
return quote(str(value), safe="._-")
+96
View File
@@ -0,0 +1,96 @@
"""千千音乐(music.91q.com)平台模块(对应 sdk/bd/index.ts)。
上游 music-lib/qianqian 的移植。匿名可用面(实测):
* 搜索(type=1)、专辑搜索 + 详情(type=3、album/info);
* 歌单详情 + 分类 + 分类歌单;
* 歌词(song/info 返回 txt URL);
* 免费曲目 128k/320k/flac 明文直链(tracklink 签名通道)。
限制(与上游一致,不造假数据):
* VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 `payPlay`;
* 歌单关键词搜索(`/v1/search?type=6`)服务端已下线(errno 23001),`search_playlist` 返回空列表;
* 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 `user_playlist`;
* 千千无独立封面接口:搜索结果/专辑/歌单自带 pic,缺失时回退 `song/info`;
* 千千无「榜单」概念:`song_list` 以歌单分类作为标签体系(tagId 为 `/v1/tracklist/category` 的 subCate id)。
凭据:cookie 从 `app/sdk/auth_store.py` 的 `get_credentials('bd')` 读取(可选,仅影响 VIP 等登录态资源)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bd import album, lyric, music_info, music_search, parse, playlist
from app.sdk.platforms.bd.util import get_qian_cookie
class _SongList:
"""MusicServer 的 songList 契约:`get_list(sort_id, tag_id, page)` / `get_tags()`。
千千把歌单分类当标签体系,`tagId` 为 subCate id(纯数字才下发,其它回落到「全部」)。
"""
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate if re.fullmatch(r"\d+", cate) else "", page, 30)
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
song_list = _SongList()
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""千千无独立封面接口:先用曲目自带的 img,缺失时回退 song/info。"""
img = song_info.get("img")
if img:
return str(img)
try:
info = await music_info.get_song_info(str(song_info.get("songmid") or ""))
return info.get("img") or None
except Exception: # noqa: BLE001 - 与原文一致:封面取不到就返回 null,不影响调用方
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
return await music_info.get_song_info(str(song_info.get("songmid") or ""))
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
tsid = str(song_info.get("songmid") or "").strip()
return f"https://music.91q.com/song/{tsid}" if tsid else "https://music.91q.com/"
def status() -> dict[str, Any]:
"""凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流)。"""
return {"logged": True} if get_qian_cookie() else {"logged": False}
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:`apis/bd.py` 反过来依赖本包的 util,顶层导入会形成环。"""
from app.sdk.apis.bd import get_music_url
return await get_music_url(song_info, quality, retry_num)
MODULE = Platform(
id="bd",
name="千千音乐",
music_search=music_search,
song_list=song_list,
album=album,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=lyric.get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+203
View File
@@ -0,0 +1,203 @@
"""千千音乐专辑(对应 sdk/bd/album.ts)。
搜索:`/v1/search?type=3`(特殊字符触发 errno 23001 时用清洗后的关键词重试)。
详情:`/v1/album/info?albumAssetCode=P...`(P 码可直接用;纯数字旧 id 走
`/v1/album/albumid2psid` 转换)。
专辑链接:`https://music.91q.com/album/<albumAssetCode>`。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.bd.util import (
APP_ID,
album_link,
api_get,
api_ok,
error_text,
join_artists,
map_track,
normalize_album_asset_code,
release_date10,
sanitize_album_keyword,
to_number,
)
class _RetryableAlbumError(RuntimeError):
"""专辑搜索失败;`retry_with_sanitized` 表示 23001(词非法)可换清洗后的词重试。"""
def __init__(self, message: str, retry_with_sanitized: bool) -> None:
super().__init__(message)
self.retry_with_sanitized = retry_with_sanitized
def _map_album_summary(item: Any) -> dict[str, Any]:
source = item if isinstance(item, dict) else {}
id = normalize_album_asset_code(source.get("albumAssetCode"))
track_list = source.get("trackList")
return {
"id": id,
"name": str(source.get("title") or "").strip(),
"cover": str(source.get("pic") or "").strip(),
"trackCount": len(track_list) if isinstance(track_list, list) else 0,
"creator": join_artists(source.get("artist")),
"description": str(source.get("introduce") or "").strip(),
"releaseDate": release_date10(source.get("releaseDate")),
"genre": str(source.get("genre") or "").strip(),
"lang": str(source.get("lang") or "").strip(),
"link": album_link(id) if id else "",
"source": "bd",
}
async def _search_album_once(word: str, page: int = 1, limit: int = 10) -> list[Any]:
body = await api_get(
"/v1/search",
{
"word": word,
"type": "3",
"pageNo": str(max(1, page)),
"pageSize": str(max(1, limit)),
"appid": APP_ID,
},
)
if not api_ok(body):
errno = body.get("errno") if isinstance(body, dict) else None
raise _RetryableAlbumError(error_text(body, "千千专辑搜索失败"), to_number(errno) == 23001)
data = (body.get("data") if isinstance(body, dict) else None) or {}
items = data.get("typeAlbum") if isinstance(data, dict) else None
return list(items) if isinstance(items, list) else []
async def search_album_items(keyword: str, page: int = 1, limit: int = 10) -> list[Any]:
"""上游 searchAlbumItems:原词失败(23001)时用清洗后的关键词重试一次。"""
words = [str(keyword if keyword is not None else "").strip()]
sanitized = sanitize_album_keyword(keyword)
if sanitized and sanitized != words[0]:
words.append(sanitized)
last_err: Exception | None = None
for word in words:
try:
return await _search_album_once(word, page, limit)
except _RetryableAlbumError as err:
last_err = err
# 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
if not err.retry_with_sanitized:
break
if last_err is not None:
raise last_err
return []
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""搜索专辑(返回歌单/专辑统一摘要形状)。"""
word = str(keyword if keyword is not None else "").strip()
if not word:
return {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
items = await search_album_items(word, page, limit)
list_out = [album for album in (_map_album_summary(item) for item in items) if album["id"]][:limit]
return {"source": "bd", "list": list_out, "total": len(items), "page": page, "limit": limit}
async def resolve_album_asset_code(id: str) -> str:
"""旧数字专辑 id → PSID(P 码);已是 P 码则原样返回。"""
raw = str(id if id is not None else "").strip()
if not raw:
raise RuntimeError("千千专辑 id 为空")
normalized = normalize_album_asset_code(raw)
if normalized:
return normalized
body = await api_get("/v1/album/albumid2psid", {"albumid": raw, "appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千专辑 id 转换失败"))
data = body.get("data") if isinstance(body, dict) else None
for item in data if isinstance(data, list) else []:
if isinstance(item, dict):
psid = normalize_album_asset_code(item.get("psid"))
if psid:
return psid
raise RuntimeError("千千专辑 asset code 未找到")
async def _fetch_album_info(id: str) -> tuple[str, dict[str, Any]]:
album_id = await resolve_album_asset_code(id)
body = await api_get("/v1/album/info", {"albumAssetCode": album_id, "appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千专辑详情获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
if not normalize_album_asset_code(data.get("albumAssetCode") if isinstance(data, dict) else None):
raise RuntimeError("千千专辑不存在或已下架")
return album_id, data
async def get_album_detail(id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""专辑详情(统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片)。"""
album_id, data = await _fetch_album_info(id)
title = str(data.get("title") or "").strip()
cover = str(data.get("pic") or "").strip()
creator = join_artists(data.get("artist"))
all_tracks = data.get("trackList")
tracks = list(all_tracks) if isinstance(all_tracks, list) else []
songs: list[dict[str, Any]] = []
for item in tracks:
song = map_track(item, album_id=album_id, album_name=title, img=cover, singer=creator)
if not song["songmid"]:
continue
# 专辑内曲目补上序号(上游 Extra.track / sort);为 0 时不写该键,与 TS 的 undefined 一致
sort = to_number(item.get("sort") if isinstance(item, dict) else None)
if sort > 0:
song["track"] = int(sort)
songs.append(song)
size = max(1, limit)
start = (max(1, page) - 1) * size
return {
"source": "bd",
"info": {
"id": album_id,
"name": title,
"img": cover,
"desc": str(data.get("introduce") or "").strip(),
"author": creator,
"releaseDate": release_date10(data.get("releaseDate")),
"genre": str(data.get("genre") or "").strip(),
"lang": str(data.get("lang") or "").strip(),
"total": len(songs),
},
"list": songs[start : start + size],
"page": page,
"limit": limit,
"total": len(songs),
"allPage": math.ceil(len(songs) / size) or 1,
}
async def get_album_songs(id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只取曲目列表。"""
detail = await get_album_detail(id, 1, 10000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态。"""
raw = str(link if link is not None else "").strip()
patterns = (
re.compile(r"music\.91q\.com/album/([A-Za-z0-9]+)"),
re.compile(r"albumAssetCode=([A-Za-z0-9]+)"),
re.compile(r"albumid=(\d+)"),
)
for pattern in patterns:
match = pattern.search(raw)
if match:
return await get_album_detail(match.group(1))
raise RuntimeError("无效的千千专辑链接")
+30
View File
@@ -0,0 +1,30 @@
"""千千音乐歌词(对应 sdk/bd/lyric.ts)。
链路:`/v1/song/info` → `data[0].lyric` 为歌词文本 URL(static-qianqian.taihe.com/*.txt)
→ 直接下载文本。返回 `{lyric, tlyric: ''}`(千千只有原文歌词,无翻译)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bd.music_info import get_song_info
from app.sdk.platforms.bd.util import request_headers
async def get_lyric(song_info: dict[str, Any]) -> dict[str, Any]:
tsid = str(song_info.get("songmid") or song_info.get("TSID") or "").strip()
if not tsid:
raise RuntimeError("千千歌词需要 songmid(TSID)")
info = await get_song_info(tsid)
lyric_url = str(info.get("lyricUrl") or "").strip()
if not lyric_url:
raise RuntimeError("千千该歌曲暂无歌词")
resp = await http_fetch(lyric_url, method="get", headers=request_headers(), timeout=15000)
if resp.status_code != 200:
raise RuntimeError(f"千千歌词下载失败(HTTP {resp.status_code})")
lyric = resp.body if isinstance(resp.body, str) else resp.raw.decode("utf-8", errors="replace")
return {"lyric": lyric, "tlyric": ""}
+34
View File
@@ -0,0 +1,34 @@
"""千千音乐单曲信息(对应 sdk/bd/musicInfo.ts)。
接口返回 `data[]`:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track
async def get_song_info(tsid: str) -> dict[str, Any]:
"""由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl)。"""
id = str(tsid if tsid is not None else "").strip()
if not id:
raise RuntimeError("千千歌曲 id(TSID)为空")
body = await api_get("/v1/song/info", {"TSID": id, "appid": APP_ID})
data = body.get("data") if isinstance(body, dict) else None
if not api_ok(body) and not isinstance(data, list):
raise RuntimeError(error_text(body, "千千歌曲信息获取失败"))
item = data[0] if isinstance(data, list) and data else None
if not isinstance(item, dict):
raise RuntimeError("千千歌曲不存在或已下架")
song = map_track(item)
return {
**song,
# 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词)
"songmid": song["songmid"] or id,
"lyricUrl": str(item.get("lyric") or "").strip(),
"raw": item,
}
+41
View File
@@ -0,0 +1,41 @@
"""千千音乐搜索(对应 sdk/bd/musicSearch.ts)。
`/v1/search?word=&type=1&pageNo=&pageSize=`,appid + 签名。
响应 `data.typeTrack[]`:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip。
注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay,
以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track, to_int, to_number
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
word = str(keyword if keyword is not None else "").strip()
if not word:
return {"list": [], "allPage": 1, "total": 0, "limit": limit, "source": "bd"}
body = await api_get(
"/v1/search",
{"word": word, "type": "1", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
)
# 23001 = 服务端不认该搜索类型/关键词,与原文一致:按空结果处理而不是报错
if not api_ok(body) and to_number(body.get("errno") if isinstance(body, dict) else None) != 23001:
raise RuntimeError(error_text(body, "千千搜索失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
tracks = data.get("typeTrack") if isinstance(data, dict) else None
raw = tracks if isinstance(tracks, list) else []
total = to_int(data.get("total") if isinstance(data, dict) else None, len(raw))
have_more = to_number(data.get("haveMore") if isinstance(data, dict) else None) == 1
songs = [song for song in (map_track(item) for item in raw) if song["songmid"]]
page_size = max(1, limit)
all_page = page + 1 if have_more else max(page, math.ceil(total / page_size) or 1)
return {"list": songs, "allPage": all_page, "total": total, "limit": limit, "source": "bd"}
+48
View File
@@ -0,0 +1,48 @@
"""千千音乐链接解析(对应 sdk/bd/parse.ts)。
歌曲:`music.91q.com/song/<TSID>`(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)。
专辑:`music.91q.com/album/<P码>` / `?albumAssetCode=` / `?albumid=`。
歌单:`music.91q.com/songlist|tracklist|playlist/<id>` / `?*id=` / 裸歌单 id。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bd import album as album_module
from app.sdk.platforms.bd import playlist as playlist_module
from app.sdk.platforms.bd.music_info import get_song_info
from app.sdk.platforms.bd.util import first_non_empty
_TSID_RE = re.compile(r"music\.91q\.com/song/(\w+)", re.ASCII)
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:歌曲链接 → Song(尽力附带直链 url/type)。"""
match = _TSID_RE.search(str(link if link is not None else ""))
if not match:
raise RuntimeError("无效的千千歌曲链接")
tsid = match.group(1)
song: dict[str, Any] = dict(await get_song_info(tsid))
try:
# 延迟导入:`apis/bd.py` 依赖本包的 util,顶层导入会让包初始化与 apis 互等
from app.sdk.apis.bd import get_download_url
hit = await get_download_url(tsid)
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
pass
song.pop("lyricUrl", None)
song.pop("raw", None)
return {**song, "songmid": first_non_empty(song.get("songmid"), tsid), "source": "bd"}
async def parse_album(link: str) -> dict[str, Any]:
return await album_module.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist_module.parse_playlist_link(link)
+201
View File
@@ -0,0 +1,201 @@
"""千千音乐歌单(对应 sdk/bd/playlist.ts)。
详情:`/v1/tracklist/info?id=&type=0&pageNo=&pageSize=`(支持分页,默认 pageSize=20)。
分类:`/v1/tracklist/category`(group.categoryName + subCate[])。
分类歌单:`/v1/tracklist/list?subCateId=&pageNo=&pageSize=`(不传 subCateId 为全部)。
搜索:上游用 `/v1/search?type=6`,但 2026-10 实测服务端已下线该搜索类型
(errno 23001 "不被支持的搜索类型(10000)"),官网搜索页也只有 单曲/歌手/专辑 三个 tab。
上游在 !state 时静默返回空列表,本实现保持一致并保留 typeSonglist 解析以备恢复。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.bd.util import (
APP_ID,
api_get,
api_ok,
error_text,
first_non_empty,
map_track,
playlist_link,
to_int,
)
def _map_playlist_summary(item: Any) -> dict[str, Any]:
source = item if isinstance(item, dict) else {}
id = "" if source.get("id") is None else str(source.get("id"))
raw_desc = str(source.get("desc") or "").strip()
tag_list = source.get("tagList")
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
return {
"id": id,
"name": str(source.get("title") or "").strip(),
"cover": str(source.get("pic") or "").strip(),
"trackCount": to_int(source.get("trackCount"), 0),
"creator": first_non_empty(
source.get("creator"),
source.get("author"),
source.get("userName"),
source.get("nickName"),
source.get("ownerName"),
),
"description": raw_desc or "、".join(tags),
"link": playlist_link(id) if id else "",
"source": "bd",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表)。"""
word = str(keyword if keyword is not None else "").strip()
empty: dict[str, Any] = {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
if not word:
return empty
body = await api_get(
"/v1/search",
{"word": word, "type": "6", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
)
# 上游此处只看 body.state(不看 errno),与服务端下线后的行为一致
if not isinstance(body, dict) or not body.get("state"):
return empty
data = body.get("data") or {}
items = data.get("typeSonglist") if isinstance(data, dict) else None
summaries = [_map_playlist_summary(item) for item in items] if isinstance(items, list) else []
list_out = [item for item in summaries if item["id"]]
total = to_int(data.get("total") if isinstance(data, dict) else None, len(list_out))
return {"source": "bd", "list": list_out, "total": total, "page": page, "limit": limit}
async def get_playlist_detail(id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize)。"""
playlist_id = str(id if id is not None else "").strip()
if not playlist_id:
raise RuntimeError("千千歌单 id 为空")
body = await api_get(
"/v1/tracklist/info",
{
"id": playlist_id,
"type": "0",
"pageNo": str(max(1, page)),
"pageSize": str(max(1, limit)),
"appid": APP_ID,
},
)
if not api_ok(body):
raise RuntimeError(error_text(body, "千千歌单获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
raw_tracks = data.get("trackList")
tracks = [item for item in raw_tracks if isinstance(item, dict)] if isinstance(raw_tracks, list) else []
songs = [song for song in (map_track(item) for item in tracks) if song["songmid"]]
title = first_non_empty(data.get("title"), playlist_id)
tag_list = data.get("tagList")
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
cover = first_non_empty(data.get("pic"), songs[0]["img"] if songs else None)
creator = first_non_empty(
data.get("creator"),
data.get("author"),
data.get("userName"),
data.get("nickName"),
data.get("ownerName"),
)
description = first_non_empty(data.get("desc"), data.get("description")) or "、".join(tags)
total = to_int(data.get("trackCount"), len(songs))
return {
"source": "bd",
"info": {
"id": playlist_id,
"name": title,
"img": cover,
"desc": description,
"author": creator,
"link": playlist_link(playlist_id),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
"allPage": math.ceil(total / max(1, limit)) or 1,
}
async def get_playlist_songs(id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只取曲目列表。"""
detail = await get_playlist_detail(id, 1, 1000)
return detail["list"]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:`{source, list: [{id, name, group, count}]}`,首项为「全部」(id='')。"""
body = await api_get("/v1/tracklist/category", {"appid": APP_ID})
if not api_ok(body):
raise RuntimeError(error_text(body, "千千歌单分类获取失败"))
list_out: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
groups = body.get("data") if isinstance(body, dict) else None
for group in groups if isinstance(groups, list) else []:
if not isinstance(group, dict):
continue
group_name = str(group.get("categoryName") or "").strip()
sub_cate = group.get("subCate")
for item in sub_cate if isinstance(sub_cate, list) else []:
if not isinstance(item, dict):
continue
id = str(item.get("id") or "").strip()
name = str(item.get("categoryName") or "").strip()
if not id or not name:
continue
list_out.append({"id": id, "name": name, "group": group_name, "count": to_int(item.get("count"), 0)})
return {"source": "bd", "list": list_out}
async def get_category_playlists(category_id: Any, page: int = 1, limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists(page >= 1,limit 1..100)。"""
cate_id = str(category_id if category_id is not None else "").strip()
page_no = max(1, to_int(page, 1))
page_size = min(100, max(1, to_int(limit, 30)))
params: dict[str, Any] = {"appid": APP_ID, "pageNo": str(page_no), "pageSize": str(page_size)}
if cate_id:
params["subCateId"] = cate_id
body = await api_get("/v1/tracklist/list", params)
if not api_ok(body):
raise RuntimeError(error_text(body, "千千分类歌单获取失败"))
data = (body.get("data") if isinstance(body, dict) else None) or {}
data = data if isinstance(data, dict) else {}
result = data.get("result")
summaries = (
[{**_map_playlist_summary(item), "categoryId": cate_id} for item in result] if isinstance(result, list) else []
)
list_out = [item for item in summaries if item["id"]]
total = to_int(data.get("total"), len(list_out))
return {"source": "bd", "list": list_out, "total": total, "page": page_no, "limit": page_size}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id。"""
raw = str(link if link is not None else "").strip()
patterns = (
re.compile(r"music\.91q\.com/(?:songlist|tracklist|playlist)/([A-Za-z0-9]+)"),
re.compile(r"(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)"),
)
for pattern in patterns:
match = pattern.search(raw)
if match:
return await get_playlist_detail(match.group(1))
if raw and "/" not in raw:
return await get_playlist_detail(raw)
raise RuntimeError("无效的千千歌单链接")
+296
View File
@@ -0,0 +1,296 @@
"""千千音乐(music.91q.com / 91q.com)公共请求层(对应 sdk/bd/util.ts)。
上游全部接口都走 `/v1/*`,参数 = 业务参数 + appid + timestamp + sign:
sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 只影响 VIP、收藏等登录态资源。
**签名串与 query 必须分开编码**:sign 用参数原值拼接(服务端拿收到的参数值重算),
query 用 `URLSearchParams` 序列化。两者共用同一份 `sign_params` 结果,且 URL 拼好后
直接交给 `http_fetch`(不再二次序列化),这是历史上「签名串 ≠ 实际 body」踩过的坑。
"""
from __future__ import annotations
import hashlib
import math
import re
import time
from typing import Any
from app.sdk.auth_store import get_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import size_formate
APP_ID = "16073360"
SECRET = "0b50b02fd0d73a9c4c8c3a781c30845f"
USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
)
REFERER = "https://music.91q.com/player"
API_BASE = "https://music.91q.com"
# 上游音质档位顺序(3000 = flac,其余为 kbps)
RATE_ORDER = ["3000", "320", "128", "64"]
# URLSearchParams 不编码的字符集。与 Python quote 的默认安全字符不同:
# URLSearchParams 保留 `*` 却编码 `~`,差一个字符服务端就验签失败,故自行实现。
_FORM_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._")
# rateFileInfo 档位 → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
_RATE_LABEL = {"128": "128k", "320": "320k", "3000": "flac"}
# 上游 sanitizeQianqianAlbumKeyword 的特殊字符集
_ALBUM_KEYWORD_SPECIALS = re.compile(r"""[::"'“”‘’()()\[\]【】,,/\\\-.]""")
def to_number(value: Any) -> float:
"""JS `Number()` 语义:None(undefined) 与不可解析 → NaN,bool → 1/0,空串 → 0。"""
if value is None:
return math.nan
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
return 0.0
try:
return float(text)
except ValueError:
return math.nan
def num_or_zero(value: Any) -> float:
"""`Number(value ?? 0)` 语义:缺失(null/undefined)视作 0,不可解析才是 NaN。"""
return 0.0 if value is None else to_number(value)
def to_int(value: Any, fallback: int = 0) -> int:
"""`Number(value) || fallback` 语义:0 / NaN / 不可解析都落到 fallback。"""
number = to_number(value)
if not number or math.isnan(number):
return fallback
return int(number)
def form_encode(value: Any) -> str:
"""`new URLSearchParams(...).toString()` 的逐字节等价实现(空格写 `+`,其余按 UTF-8 编码)。"""
out: list[str] = []
for byte in str(value).encode("utf-8"):
char = chr(byte)
if char in _FORM_SAFE:
out.append(char)
elif char == " ":
out.append("+")
else:
out.append(f"%{byte:02X}")
return "".join(out)
def get_qian_cookie() -> str:
"""千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致)。"""
return get_cookie("bd")
def request_headers() -> dict[str, str]:
headers = {
"User-Agent": USER_AGENT,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
cookie = get_qian_cookie()
if cookie:
headers["Cookie"] = cookie
return headers
def sign_params(params: dict[str, Any]) -> dict[str, Any]:
"""写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5。"""
signed: dict[str, Any] = {str(key): value for key, value in params.items()}
signed["timestamp"] = str(int(time.time()))
payload = "&".join(f"{key}={signed[key]}" for key in sorted(signed)) + SECRET
signed["sign"] = hashlib.md5(payload.encode("utf-8")).hexdigest()
return signed
def signed_url(path: str, params: dict[str, Any]) -> str:
"""签名后的完整 URL:签名用原值,query 用 URLSearchParams 等价编码。"""
query = "&".join(f"{key}={form_encode(value)}" for key, value in sign_params(params).items())
return f"{API_BASE}{path}?{query}"
async def api_get(path: str, params: dict[str, Any]) -> Any:
"""发起带签名的 GET,返回响应 JSON body(非 200 按原文案抛错)。"""
resp = await http_fetch(signed_url(path, params), method="get", headers=request_headers(), timeout=15000)
if resp.status_code != 200:
raise RuntimeError(f"千千音乐接口失败(HTTP {resp.status_code})")
return resp.body if resp.body is not None else {}
def api_ok(body: Any) -> bool:
"""上游状态判定:state=true 或 errno=22000 视为成功。"""
if not isinstance(body, dict):
return False
return body.get("state") is True or to_number(body.get("errno")) == 22000
def error_text(body: Any, prefix: str) -> str:
"""上游错误文案:errmsg 优先,缺失时拼 errno(两种文案都逐字沿用原实现)。"""
if not isinstance(body, dict):
return prefix
message = str(body.get("errmsg") or "").strip()
if message:
return message
return f"{prefix}(errno {body.get('errno')})"
async def track_link(tsid: str, rate: str) -> Any:
"""tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path)。"""
return await api_get("/v1/song/tracklink", {"TSID": tsid, "appid": APP_ID, "rate": rate})
def interval_of(duration: Any) -> str | int:
"""时间格式 mm:ss(duration 为秒;无效或 <=0 显示 0)。"""
seconds = to_number(duration)
if math.isnan(seconds) or seconds <= 0:
return 0
return f"{int(seconds // 60):02d}:{int(seconds % 60):02d}"
def join_artists(artists: Any) -> str:
"""千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接)。"""
if not isinstance(artists, list) or not artists:
return ""
names: list[str] = []
seen: set[str] = set()
def add(name: Any) -> None:
text = "" if name is None else str(name).strip()
if not text or text in seen:
return
seen.add(text)
names.append(text)
for item in artists:
if isinstance(item, dict) and to_number(item.get("artistType")) == 38:
add(item.get("name"))
if not names:
for item in artists:
if isinstance(item, dict):
add(item.get("name"))
return "、".join(names)
def normalize_album_asset_code(id: Any) -> str:
"""上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P。"""
text = ("" if id is None else str(id)).strip()
if len(text) < 2:
return ""
return f"P{text[1:]}" if text[0] in ("p", "P") else ""
def sanitize_album_keyword(keyword: Any) -> str:
"""上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(23001 时重试用)。"""
trimmed = ("" if keyword is None else str(keyword)).strip()
if not trimmed:
return ""
return " ".join(part for part in _ALBUM_KEYWORD_SPECIALS.sub(" ", trimmed).split() if part)
def first_non_empty(*values: Any) -> str:
for value in values:
text = "" if value is None else str(value).strip()
if text:
return text
return ""
def release_date10(raw: Any) -> str:
"""上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD)。"""
text = ("" if raw is None else str(raw)).strip()
return text[:10] if len(text) >= 10 else text
def rate_stats(rate_file_info: Any, duration: float) -> dict[str, int]:
"""上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps)。"""
for rate in RATE_ORDER:
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
if not isinstance(info, dict):
continue
size = to_number(info.get("size"))
if not size > 0:
continue
if duration > 0:
return {"size": int(size), "bitrate": int(size * 8 / 1000 / duration)}
return {"size": int(size), "bitrate": 800 if rate == "3000" else int(rate)}
return {"size": 0, "bitrate": 0}
def rate_types(rate_file_info: Any) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""rateFileInfo → 既有 Quality 桶的 types / _types。"""
types: list[dict[str, Any]] = []
type_map: dict[str, dict[str, Any]] = {}
for rate in ("128", "320", "3000"):
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
if not isinstance(info, dict):
continue
size = to_number(info.get("size"))
if not size > 0:
continue
label = _RATE_LABEL[rate]
size_text = size_formate(int(size))
types.append({"type": label, "size": size_text})
type_map[label] = {"size": size_text}
return types, type_map
def map_track(
item: Any,
*,
album_id: str = "",
album_name: str = "",
img: str | None = None,
singer: str = "",
) -> dict[str, Any]:
"""千千曲目字段 → MusicServer 统一 Song 形状。
搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
歌单详情用 TSID + albumTitle + pic。
注:上游 Search 会跳过 isVip != 0 的曲目;本实现保留它们并标记 payPlay,
取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
"""
source = item if isinstance(item, dict) else {}
duration = num_or_zero(source.get("duration"))
stats = rate_stats(source.get("rateFileInfo"), duration)
types, type_map = rate_types(source.get("rateFileInfo"))
return {
"songmid": first_non_empty(source.get("TSID"), source.get("assetId"), source.get("tsid")),
"name": str(source.get("title") or "").strip(),
"singer": first_non_empty(join_artists(source.get("artist")), singer),
"albumId": first_non_empty(normalize_album_asset_code(source.get("albumAssetCode")), album_id),
"albumName": first_non_empty(source.get("albumTitle"), album_name),
"interval": interval_of(duration),
"size": stats["size"] or 0,
"bitrate": stats["bitrate"] or 0,
"payPlay": num_or_zero(source.get("isVip")) != 0 or num_or_zero(source.get("isPaid")) != 0,
"img": first_non_empty(source.get("pic"), img) or None,
"lrc": None,
"lrcx": "",
"types": types,
"_types": type_map,
"source": "bd",
}
def song_link(tsid: str) -> str:
return f"https://music.91q.com/song/{tsid}"
def album_link(id: str) -> str:
return f"https://music.91q.com/album/{id}"
def playlist_link(id: str) -> str:
return f"https://music.91q.com/songlist/{id}"
+57
View File
@@ -0,0 +1,57 @@
"""哔哩哔哩(bili)平台模块(对应 sdk/bili/index.ts)。
上游 music-lib/bilibili 的移植,能力位:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单
(合集 ugc_season + 多分P稿件)/ 链接解析(BV/av、分P)/ 封面 / 扫码登录。
不实现(上游即未实现):专辑、歌词(空实现)、榜单、热搜、歌手、评论、个人歌单。
取流入口在 ``app/sdk/apis/bili.py``,songmid 约定 ``<bvid>|<cid>``。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bili import lyric, music_search, parse, pic, playlist
from app.sdk.platforms.bili.util import parse_song_id
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:apis 模块反向依赖本包的 util,顶层导入不必要地增加耦合。"""
from app.sdk.apis.bili import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str:
return await pic.get_pic(song_info)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return "https://www.bilibili.com/"
try:
page = int(song_info.get("page") or 0)
except (TypeError, ValueError):
page = 0
return f"https://www.bilibili.com/video/{bvid}{f'?p={page}' if page > 1 else ''}"
MODULE = Platform(
id="bili",
name="哔哩哔哩",
music_search=music_search,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+14
View File
@@ -0,0 +1,14 @@
"""Bilibili 歌词(对应 sdk/bili/lyric.ts)。
上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串。
B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
返回统一结构 ``{"lyric": "", "tlyric": ""}``,让 /lyric/bili 有响应而不是 404。
"""
from __future__ import annotations
from typing import Any
async def get_lyric(_song_info: dict[str, Any] | None = None) -> dict[str, str]:
return {"lyric": "", "tlyric": ""}
+83
View File
@@ -0,0 +1,83 @@
"""Bilibili 搜索(对应 sdk/bili/musicSearch.ts,上游 bilibili/song.go 的 ``Search``)。
接口: x/web-interface/search/type?search_type=video
上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),
本移植保持一致,只是把逐个串行改成 4 并发;单条 view 失败按上游语义跳过该条。
实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;
风控时返回 -412/-352,会抛错由路由兜底。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
clean_title,
encode_uri_component,
fetch_view,
make_song,
map_limit,
to_number,
)
SEARCH_URL = "https://api.bilibili.com/x/web-interface/search/type"
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,B 站搜索无重试语义
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站搜索失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站搜索失败({body.get('code')}{f': {message}' if message else ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
pages = as_list(view.get("pages"))
if not pages:
return None
first = pages[0] if isinstance(pages[0], dict) else {}
root_title = clean_title(item.get("title"))
display_title = str(first.get("part") or "")
if not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
return make_song(
bvid=str(item["bvid"]),
cid=first.get("cid"),
name=display_title,
artist=str(item.get("author") or owner.get("name") or ""),
album=str(item["bvid"]),
duration=first.get("duration"),
cover=str(item.get("pic") or view.get("pic") or ""),
page=1,
)
songs = await map_limit(items, 4, one)
result = [song for song in songs if song]
total = int(to_number(data.get("numResults"))) or len(result)
num_pages = int(to_number(data.get("numPages")))
return {
"list": result,
"allPage": num_pages or p + (1 if len(result) >= size else 0),
"total": total,
"limit": size,
"source": "bili",
}
+93
View File
@@ -0,0 +1,93 @@
"""Bilibili 链接解析(对应 sdk/bili/parse.ts,上游 bilibili/song.go 的 ``Parse``)。
支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parse_playlist_link。
上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.playlist import parse_playlist_link
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
extract_aid,
extract_bvid,
extract_page,
fetch_page_list,
fetch_view,
make_song,
normalize_cover,
)
async def parse_song(link: str) -> dict[str, Any]:
raw = str(link or "").strip()
bvid = extract_bvid(raw)
aid = extract_aid(raw)
if not bvid and not aid:
raise RuntimeError("无效的B站链接:未找到 BV/av 号")
view = await fetch_view(bvid or aid)
pages = as_list(view.get("pages"))
# 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if view.get("ugc_season") or len(pages) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
if len(pages) <= 1:
try:
page_list = await fetch_page_list(view.get("bvid") or bvid)
if len(page_list) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
except Exception as err:
if "歌单解析" in str(err):
raise
# 分P列表接口失败时按上游语义忽略
if not pages:
raise RuntimeError("未找到视频分P")
page = extract_page(raw)
if page > len(pages):
page = 1
target = pages[page - 1] if isinstance(pages[page - 1], dict) else {}
root_title = str(view.get("title") or "")
display_title = str(target.get("part") or "")
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
cid = str(target.get("cid") if target.get("cid") is not None else "")
song = make_song(
bvid=str(view.get("bvid") or bvid),
cid=cid,
name=display_title,
artist=str(owner.get("name") or ""),
album=str(view.get("bvid") or bvid),
duration=target.get("duration"),
cover=normalize_cover(view.get("pic") or ""),
page=page,
)
# 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if cid:
try:
from app.sdk.apis.bili import get_audio_url
audio = await get_audio_url(str(song["bvid"]), cid)
urls = audio.get("urls") or []
if urls:
song["url"] = urls[0]
song["type"] = audio.get("type")
except Exception: # noqa: BLE001 - 忽略:元数据仍可返回
pass
return song
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单/合集链接解析(上游 bilibili/playlist.go 的 ``ParsePlaylist``)。"""
return await parse_playlist_link(link)
+21
View File
@@ -0,0 +1,21 @@
"""Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import fetch_view, normalize_cover, parse_song_id
async def get_pic(song_info: dict[str, Any]) -> str:
direct = str(song_info.get("img") or "")
if direct.startswith(("http://", "https://")):
return direct
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return ""
try:
view = await fetch_view(bvid)
return normalize_cover(view.get("pic") or "")
except Exception: # noqa: BLE001 - 与原文一致:封面取不到返回空串,不影响调用方
return ""
+318
View File
@@ -0,0 +1,318 @@
"""Bilibili 歌单(对应 sdk/bili/playlist.ts,上游 bilibili/playlist.go)。
B 站的「歌单」= 合集 ugc_season / 多分P稿件:
* search_playlist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
* get_playlist_detail / parse_playlist_link: id 约定 ``season:<season_id>:<mid>:<bvid>`` 或 ``bvid:<bvid>``;
* get_categories / get_category_playlists: 上游显式返回 ErrPlaylistCategoriesUnsupported,保持不支持(抛错)。
注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.bili.music_search import SEARCH_URL
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
build_songs_from_pages,
build_songs_from_season_sections,
clean_title,
encode_uri_component,
extract_aid,
extract_bvid,
fetch_page_list,
fetch_season_archive_index,
fetch_season_songs,
fetch_view,
map_limit,
normalize_cover,
to_number,
)
def _count_season_episodes(sections: list[Any]) -> int:
count = 0
for sec in sections or []:
if isinstance(sec, dict):
episodes = sec.get("episodes")
count += len(episodes) if isinstance(episodes, list) else 0
return count
def _season_key(season_id: Any, mid: Any, bvid: str) -> str:
return f"season:{season_id}:{mid}:{bvid}"
def _bvid_key(bvid: str) -> str:
return f"bvid:{bvid}"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力)。"""
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 20), 50)
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
resp = await api_get(url)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"B站歌单搜索失败(HTTP {resp.status_code})")
if body.get("code") != 0:
message = body.get("message")
raise RuntimeError(f"B站歌单搜索失败({body.get('code')}{f': {message}' if message else ''})")
data = as_dict(body.get("data"))
items = as_list(data.get("result"))
seen: set[str] = set()
async def one(item: Any, _index: int) -> dict[str, Any] | None:
if not isinstance(item, dict) or not item.get("bvid"):
return None
view = await fetch_view(item["bvid"])
cover = normalize_cover(item.get("pic") or view.get("pic") or "")
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count and season.get("id") and owner.get("mid"):
try:
track_count = len(await fetch_season_songs(owner.get("mid"), season.get("id")))
except Exception: # noqa: BLE001 - 上游 fetchSeasonSongs 失败同样保持 trackCount 0
pass
stat = as_dict(season.get("stat"))
return {
"id": playlist_id,
"name": str(season.get("title") or clean_title(item.get("title"))),
"cover": normalize_cover(season.get("cover")) or cover,
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
"creator": str(owner.get("name") or item.get("author") or ""),
"description": str(season.get("intro") or ""),
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
pages = as_list(view.get("pages"))
if len(pages) > 1:
playlist_id = _bvid_key(str(item["bvid"]))
if playlist_id in seen:
return None
seen.add(playlist_id)
return {
"id": playlist_id,
"name": clean_title(item.get("title")),
"cover": cover,
"trackCount": len(pages),
"creator": str(owner.get("name") or item.get("author") or ""),
"description": "",
"link": f"https://www.bilibili.com/video/{item['bvid']}",
"source": "bili",
}
return None
result = await map_limit(items, 4, one)
result_list = [item for item in result if item]
return {"list": result_list, "page": p, "limit": size, "total": len(result_list), "source": "bili"}
async def _detail_from_bvid(bvid: str) -> dict[str, Any]:
"""由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据)。"""
view = await fetch_view(bvid)
real_bvid = str(view.get("bvid") or bvid)
pages = as_list(view.get("pages"))
if len(pages) <= 1:
try:
page_list = await fetch_page_list(real_bvid)
except Exception: # noqa: BLE001 - 分P列表接口失败按上游语义忽略
page_list = []
if page_list:
pages = page_list
if not pages:
raise RuntimeError("未找到视频分P")
owner = as_dict(view.get("owner"))
songs = build_songs_from_pages(
real_bvid, str(view.get("title") or ""), str(owner.get("name") or ""), str(view.get("pic") or ""), pages
)
return {
"info": {
"name": str(view.get("title") or real_bvid),
"img": normalize_cover(view.get("pic") or ""),
"desc": str(view.get("desc") or ""),
"author": str(owner.get("name") or ""),
},
"songs": songs,
}
async def _detail_from_season(season_id: str, mid: str, bvid: str) -> dict[str, Any]:
"""由 ``season:<season_id>:<mid>:<bvid>`` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支)。"""
info: dict[str, Any] = {"name": "", "img": "", "desc": "", "author": ""}
songs: list[dict[str, Any]] = []
if bvid:
view = await fetch_view(bvid)
season = as_dict(view.get("ugc_season")) or None
if season:
archive_index: dict[str, dict[str, Any]] = {}
season_title = str(season.get("title") or "")
season_cover = str(season.get("cover") or "")
# 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
archive_index = idx["index"]
season_title = season_title or idx["title"]
season_cover = season_cover or idx["cover"]
owner = as_dict(view.get("owner"))
songs = build_songs_from_season_sections(
as_list(season.get("sections")),
season_title,
season_cover,
str(owner.get("name") or ""),
archive_index,
)
info = {
"name": season_title or str(view.get("title") or ""),
"img": normalize_cover(season_cover or view.get("pic") or ""),
"desc": str(season.get("intro") or ""),
"author": str(owner.get("name") or ""),
}
if not songs:
songs = await fetch_season_songs(mid, season_id)
if not info["name"]:
try:
idx = await fetch_season_archive_index(mid, season_id)
except Exception: # noqa: BLE001
idx = None
if idx:
info = {**info, "name": idx["title"] or "", "img": normalize_cover(idx["cover"] or "")}
if not songs:
raise RuntimeError("合集为空或合集接口不可用")
return {"info": info, "songs": songs}
async def _resolve_id(playlist_id: str) -> dict[str, Any]:
"""解析 id(兼容链接/裸 BV/av/bvid: 前缀)。"""
raw = str(playlist_id or "").strip()
if re.match(r"^https?:", raw, re.IGNORECASE):
return {"kind": "link", "value": raw}
if re.match(r"^season:", raw, re.IGNORECASE):
return {"kind": "season", "value": raw, "parts": raw.split(":")}
# 注意先去 `bvid:` 前缀再提 BV:大小写不敏感的 BV 匹配会把前缀 "bvid" 本身当成 BV 号
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if bvid:
return {"kind": "bvid", "value": bvid}
aid = extract_aid(raw)
if aid:
view = await fetch_view(aid)
real_bvid = str(view.get("bvid") or "")
if not real_bvid:
raise RuntimeError("未找到视频 BV 号")
return {"kind": "bvid", "value": real_bvid}
raise RuntimeError("无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)")
def _page_slice(detail: dict[str, Any], page: Any, limit: Any) -> dict[str, Any]:
p = max(1, int(to_number(page)) or 1)
size = min(max(1, int(to_number(limit)) or 100), 10000)
songs = detail["songs"]
return {
"source": "bili",
"info": detail["info"],
"list": songs[(p - 1) * size : p * size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(契约键 playlist.get_playlist_detail)。"""
target = await _resolve_id(playlist_id)
if target["kind"] == "link":
return await parse_playlist_link(target["value"])
if target["kind"] == "season":
parts = target.get("parts") or []
if len(parts) < 3:
raise RuntimeError("无效的合集 id")
bvid = parts[3] if len(parts) > 3 else ""
return _page_slice(await _detail_from_season(parts[1], parts[2], bvid), page, limit)
return _page_slice(await _detail_from_bvid(target["value"]), page, limit)
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs 的等价导出(只返回歌曲数组)。"""
detail = await get_playlist_detail(playlist_id, 1, 10000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量)。"""
raw = str(link or "").strip()
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
if not bvid:
aid = extract_aid(raw)
if not aid:
raise RuntimeError("无效的B站歌单链接:未找到 BV/av 号")
view = await fetch_view(aid)
return await _parse_playlist_view(str(view.get("bvid") or ""), view)
view = await fetch_view(bvid)
return await _parse_playlist_view(bvid, view)
async def _parse_playlist_view(bvid: str, view: dict[str, Any]) -> dict[str, Any]:
owner = as_dict(view.get("owner"))
season = as_dict(view.get("ugc_season")) or None
if season:
playlist_id = _season_key(season.get("id"), owner.get("mid"), bvid)
detail = await _detail_from_season(
str(season.get("id")), str(owner.get("mid") if owner.get("mid") is not None else ""), bvid
)
sections = as_list(season.get("sections"))
track_count = _count_season_episodes(sections)
if not track_count:
track_count = len(detail["songs"])
stat = as_dict(season.get("stat"))
return {
"source": "bili",
"id": playlist_id,
"info": {
**detail["info"],
"trackCount": track_count,
"playCount": int(to_number(stat.get("view"))) or None,
},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
detail = await _detail_from_bvid(bvid)
return {
"source": "bili",
"id": _bvid_key(bvid),
"info": {**detail["info"], "trackCount": len(detail["songs"])},
"list": detail["songs"],
"page": 1,
"limit": max(len(detail["songs"]), 1),
"total": len(detail["songs"]),
}
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported。"""
raise RuntimeError("B站不支持歌单分类(上游未实现)")
+425
View File
@@ -0,0 +1,425 @@
"""Bilibili 公共请求助手(对应 sdk/bili/util.ts,上游 music-lib/bilibili 的共享逻辑)。
与上游的差异(2026-10-02 实测):
1) 上游硬编码的默认 cookie(buvid3=...; SESSDATA=your_sessdata;)当前会被 B 站风控:
带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
2) 请求层不跟随 302,取流的手动跟跳在 ``app/sdk/apis/bili.py`` 内处理。
3) 登录凭据来自 ``app/sdk/auth_store.py`` 的 bili 记录(扫码登录写入)。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"
)
REFERER = "https://www.bilibili.com/"
HOME_URL = "https://www.bilibili.com/"
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def bili_headers(cookie: str = "") -> dict[str, str]:
"""上游 UserAgent/Referer 组合(所有接口统一携带)。"""
headers = {
"User-Agent": UA,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
if cookie:
headers["Cookie"] = cookie
return headers
def get_login_cookie() -> str:
"""已保存的登录 Cookie(未登录返回空串)。"""
cred = get_credentials("bili") or {}
return str(cred.get("cookie") or "")
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
async def _fetch_home_cookies() -> str:
"""首页下发的新鲜 Cookie(http_fetch 保留了原始多值 Set-Cookie,同名头不会互相覆盖)。"""
resp = await http_fetch(HOME_URL, headers=bili_headers(), timeout=15_000)
jar = _cookie_map(resp.set_cookies)
return "; ".join(f"{key}={value}" for key, value in jar.items())
_anon_cookie = ""
_anon_lock = asyncio.Lock()
async def get_cookie() -> str:
"""请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘)。"""
saved = get_login_cookie()
if saved:
return saved
global _anon_cookie
if _anon_cookie:
return _anon_cookie
async with _anon_lock:
if _anon_cookie:
return _anon_cookie
try:
_anon_cookie = await _fetch_home_cookies()
except Exception: # noqa: BLE001 - 拿不到就用无 Cookie 请求(实测同样可用)
_anon_cookie = ""
return _anon_cookie
async def api_get(url: str, cookie: str | None = None) -> HttpResponse:
"""带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名)。"""
ck = await get_cookie() if cookie is None else cookie
return await http_fetch(url, headers=bili_headers(ck), timeout=15_000)
def normalize_cover(cover: Any) -> str:
"""封面协议补全(上游 normalizeCover)。"""
text = str(cover or "")
if not text:
return ""
return f"https:{text}" if text.startswith("//") else text
def clean_title(title: Any) -> str:
"""搜索标题去高亮标签(上游 cleanTitle)。"""
if not title:
return ""
return re.sub(r'<em class="keyword">|</em>', "", str(title))
def parse_song_id(songmid: Any) -> dict[str, str]:
"""songmid 约定:``<bvid>|<cid>``(与上游 ID 格式一致)。"""
raw = str(songmid if songmid is not None else "")
idx = raw.find("|")
if idx < 0:
return {"bvid": raw, "cid": ""}
return {"bvid": raw[:idx], "cid": raw[idx + 1 :]}
def extract_bvid(link: str) -> str:
"""从链接/id 提取 BV 号(兼容小写 bv)。"""
match = re.search(r"BV[0-9A-Za-z]+", str(link or ""), re.IGNORECASE)
return match.group(0) if match else ""
def extract_page(link: str) -> int:
"""从链接提取分P(上游 ``[?&]p=(\\d+)``)。"""
match = re.search(r"[?&]p=(\d+)", str(link or ""))
page = int(match.group(1)) if match else 0
return page if page > 0 else 1
def extract_aid(link: str) -> str:
"""从链接提取 av 号(上游未支持,任务要求兼容)。"""
match = re.search(r"\bav(\d+)\b", str(link or ""), re.IGNORECASE)
return match.group(1) if match else ""
def to_number(value: Any) -> float:
"""JS 的 Number(value):空值/非数字一律 0。"""
try:
return float(value)
except (TypeError, ValueError):
return 0.0
async def map_limit(items: list[Any], limit: int, fn: Any) -> list[Any]:
"""并发映射(限制并发数,单条失败返回 None)。"""
source = list(items or [])
out: list[Any] = [None] * len(source)
if not source:
return out
size = max(1, min(int(limit or 1), len(source)))
cursor = 0
async def worker() -> None:
nonlocal cursor
while True:
index = cursor
cursor += 1
if index >= len(source):
return
try:
out[index] = await fn(source[index], index)
except Exception: # noqa: BLE001 - 单条失败按上游语义跳过
out[index] = None
await asyncio.gather(*(worker() for _ in range(size)))
return out
def make_song(
*,
bvid: str,
cid: Any,
name: str = "",
artist: str = "",
album: str = "",
duration: Any = 0,
cover: str = "",
page: int = 1,
) -> dict[str, Any]:
"""统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid)。"""
seconds = to_number(duration)
link = f"https://www.bilibili.com/video/{bvid}" + (f"?p={page}" if page > 1 else "")
return {
"songmid": f"{bvid}|{cid}",
"name": name or "",
"singer": artist or "",
"albumId": bvid or "",
"albumName": album or bvid or "",
"interval": format_play_time(seconds) if seconds else 0,
"img": normalize_cover(cover) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "bili",
"bvid": bvid,
"cid": str(cid if cid is not None else ""),
"page": page,
"link": link,
}
def assert_api_ok(what: str, status_code: int, body: Any) -> None:
"""B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误。"""
if status_code != 200 or not isinstance(body, dict) or body.get("code") != 0:
code = body.get("code") if isinstance(body, dict) else None
message = body.get("message") if isinstance(body, dict) else None
reason = code if code is not None else f"HTTP {status_code}"
raise RuntimeError(f"B站{what}获取失败({reason}{f': {message}' if message else ''})")
async def fetch_view(video_id: Any) -> dict[str, Any]:
"""视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点。"""
raw = str(video_id or "").strip()
if not raw:
raise RuntimeError("B站视频 id 为空")
if re.match(r"^BV", raw, re.IGNORECASE):
query = f"bvid={encode_uri_component(raw)}"
else:
query = f"aid={encode_uri_component(re.sub('^av', '', raw, flags=re.IGNORECASE))}"
resp = await api_get(f"https://api.bilibili.com/x/web-interface/view?{query}")
assert_api_ok("视频信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
if not isinstance(data, dict):
raise RuntimeError("B站视频信息获取失败(data 为空)")
return data
async def fetch_page_list(bvid: str) -> list[Any]:
"""分P列表(x/player/pagelist),返回 data 数组。"""
resp = await api_get(f"https://api.bilibili.com/x/player/pagelist?bvid={encode_uri_component(bvid)}")
assert_api_ok("分P列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
return data if isinstance(data, list) else []
def _season_page_url(mid: Any, season_id: Any, page_num: int, page_size: int) -> str:
return (
"https://api.bilibili.com/x/space/ugc/season?"
f"mid={mid}&season_id={season_id}&page_num={page_num}&page_size={page_size}"
)
async def fetch_season_archive_index(mid: Any, season_id: Any) -> dict[str, Any]:
"""合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex)。
该接口现已 404,调用方需容错;返回 ``{"index", "title", "cover"}``,
index 以 ``bvid:<bvid>`` / ``cid:<cid>`` 为键。
"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
index: dict[str, dict[str, Any]] = {}
season_title = ""
season_cover = ""
processed = 0
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict):
continue
meta = {
"title": str(arc.get("title") or ""),
"cover": normalize_cover(arc.get("cover") or ""),
"duration": to_number(arc.get("duration")),
}
if arc.get("bvid"):
index[f"bvid:{arc['bvid']}"] = meta
if arc.get("cid"):
index[f"cid:{arc['cid']}"] = meta
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return {"index": index, "title": season_title, "cover": season_cover}
def build_songs_from_pages(
bvid: str, root_title: str, author: str, cover: str, pages: list[Any]
) -> list[dict[str, Any]]:
"""由分P数组构造歌曲(上游 buildSongsFromPages)。"""
songs: list[dict[str, Any]] = []
for i, page in enumerate(pages or []):
part = str(page.get("part") or "") if isinstance(page, dict) else ""
display_title = part
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
songs.append(
make_song(
bvid=bvid,
cid=page.get("cid") if isinstance(page, dict) else None,
name=display_title,
artist=author,
album=bvid,
duration=page.get("duration") if isinstance(page, dict) else 0,
cover=cover,
page=i + 1,
)
)
return songs
def build_songs_from_season_sections(
sections: list[Any],
season_title: str,
season_cover: str,
artist_name: str,
archive_index: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections)。"""
index = archive_index or {}
songs: list[dict[str, Any]] = []
artist = artist_name or season_title
for sec in sections or []:
if not isinstance(sec, dict):
continue
for ep in sec.get("episodes") or []:
if not isinstance(ep, dict):
continue
cover = str(ep.get("cover") or "")
meta = index.get(f"bvid:{ep.get('bvid')}") or (index.get(f"cid:{ep.get('cid')}") if ep.get("cid") else None)
if not cover:
arc = as_dict(ep.get("arc"))
cover = str(arc.get("pic") or "")
if not cover:
cover = season_cover or (meta or {}).get("cover", "")
duration = to_number(ep.get("duration"))
if not duration:
page = as_dict(ep.get("page"))
duration = to_number(page.get("duration"))
if not duration:
arc = as_dict(ep.get("arc"))
duration = to_number(arc.get("duration"))
if not duration:
duration = to_number((meta or {}).get("duration"))
page = as_dict(ep.get("page"))
arc = as_dict(ep.get("arc"))
name = str(ep.get("title") or arc.get("title") or page.get("part") or (meta or {}).get("title") or "")
songs.append(
make_song(
bvid=str(ep.get("bvid") or ""),
cid=ep.get("cid"),
name=name,
artist=artist,
album=str(ep.get("bvid") or ""),
duration=duration,
cover=cover,
page=1,
)
)
return songs
async def fetch_season_songs(mid: Any, season_id: Any) -> list[dict[str, Any]]:
"""合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错)。"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
all_songs: list[dict[str, Any]] = []
processed = 0
season_title = ""
season_cover = ""
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict) or not arc.get("cid"):
continue
all_songs.append(
make_song(
bvid=str(arc.get("bvid") or ""),
cid=arc.get("cid"),
name=str(arc.get("title") or ""),
artist="",
album=season_title or str(arc.get("bvid") or ""),
duration=arc.get("duration"),
cover=normalize_cover(arc.get("cover") or season_cover),
page=1,
)
)
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return all_songs
+68
View File
@@ -0,0 +1,68 @@
"""5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块(对应 sdk/5sing/index.ts)。
上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
榜单/热搜/歌手/评论(上游均无)。
取流入口在 ``app/sdk/apis/fivesing.py``;songmid 约定 ``<songId>|<songType>``(如 "932557|yc")。
登录:上游 New(cookie) 的 cookie 会随请求发送;本移植从 auth_store 的 5sing 记录读取(无则匿名)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.fivesing import lyric, music_search, parse, playlist
from app.sdk.platforms.fivesing.util import build_song_link, extract_song_id, get_status
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现,避免 apis → 本包 util 的初始化顺序耦合。"""
from app.sdk.apis.fivesing import get_music_url
return await get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
ids = extract_song_id(song_info.get("songmid") or song_info.get("id"), song_info.get("songType"))
if not ids["songID"] or not ids["songType"]:
return "http://5sing.kugou.com/"
return build_song_link(ids["songType"], ids["songID"])
class _UserPlaylist:
"""上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义。"""
@staticmethod
async def get_list(_page: int = 1, _limit: int = 20) -> dict[str, Any]:
raise RuntimeError("user playlists not supported")
user_playlist = _UserPlaylist()
def status() -> dict[str, Any]:
return get_status()
MODULE = Platform(
id="5sing",
name="5sing",
music_search=music_search,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_music_detail_page_url=get_music_detail_page_url,
)
+41
View File
@@ -0,0 +1,41 @@
"""5sing 歌词(对应 sdk/5sing/lyric.ts,上游 fivesing/lyric.go 的 GetLyrics)。
接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
→ data.dynamicWords 即 LRC 文本。
上游 ID 约定:model.Song.ID = "<songid>|<songtype>",从 songmid 的 "|" 拆分
(路由层只透传 songmid),也接受 songInfo.songType 字段。
统一接口:返回 ``{lyric, tlyric: ''}``(5sing 无翻译歌词)。
实况(2026-10-23 匿名实测):newget 响应不再包含 dynamicWords(搜索前 10 条与多个关键词均如此,
TS 版同样结果),因此此刻匿名取歌词会如实抛 "lyrics not found";绑定 cookie 后是否有变化尚未验证。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_dynamic_lyric
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
async def get_lyric_text(song_id: str, song_type: str) -> str:
"""直接返回歌词文本(内部/测试用)。"""
lyric = await fetch_dynamic_lyric(song_id, song_type)
if not lyric:
raise RuntimeError("lyrics not found")
return lyric
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"]:
raise RuntimeError("invalid id")
lyric = await fetch_dynamic_lyric(ids["songID"], ids["songType"])
if not lyric:
raise RuntimeError("lyrics not found")
return {"lyric": lyric, "tlyric": ""}
+118
View File
@@ -0,0 +1,118 @@
"""5sing 歌曲信息 / 取流底层(对应 sdk/5sing/musicInfo.ts)。
链路(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink):
取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址;
上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
get_first_valid,
kind_name,
)
QUALITY_TIERS = ("sq", "hq", "lq")
def _tier_url(data: dict[str, Any], tier: str) -> str:
"""data 里某档位的可用地址(主地址 → backup)。"""
return get_first_valid(data.get(f"{tier}url"), data.get(f"{tier}url_backup"))
async def fetch_audio_link(song_id: str, song_type: str, prefer_low: bool = False) -> dict[str, str]:
"""上游 fetchAudioLink:仅获取音频链接。"""
url = (
f"{MOBILE_API}/song/getSongUrl?songid={encode_uri_component(song_id)}"
f"&songtype={encode_uri_component(song_type)}"
)
resp = await fetch_5sing_json(url)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 取流失败(HTTP {resp.status_code})")
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
if code != 1000:
# 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
message = body.get("message")
raise RuntimeError(f"5sing 取流失败({message})" if message else "api returned error code")
data = as_dict(body.get("data"))
order = tuple(reversed(QUALITY_TIERS)) if prefer_low else QUALITY_TIERS
for tier in order:
hit = _tier_url(data, tier)
if hit and hit.startswith(("http://", "https://")):
return {"type": tier, "url": hit}
raise RuntimeError("no valid download url found")
async def fetch_new_get(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象)。"""
url = f"{MOBILE_API}/song/newget?songid={encode_uri_component(song_id)}&songtype={encode_uri_component(song_type)}"
resp = await fetch_5sing_json(url)
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 歌曲信息获取失败(HTTP {resp.status_code})")
data = resp.body.get("data")
return data if isinstance(data, dict) else {}
async def fetch_dynamic_lyric(song_id: str, song_type: str) -> str:
"""上游 newget 歌词:data.dynamicWords(LRC 文本)。"""
data = await fetch_new_get(song_id, song_type)
return str(data.get("dynamicWords") or "")
async def fetch_song_info(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。"""
hit = await fetch_audio_link(song_id, song_type)
name = ""
artist = ""
cover = ""
try:
data = await fetch_new_get(song_id, song_type)
name = str(data.get("SN") or "")
user = as_dict(data.get("user"))
artist = str(user.get("NN") or "")
cover = str(user.get("I") or "")
except Exception: # noqa: BLE001 - 上游 newget 失败时继续(不阻断)
pass
if not name:
name = f"5sing_{song_type}_{song_id}"
return {
"songmid": f"{song_id}|{song_type}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": cover or None,
"lrc": None,
"types": [],
"_types": {},
"url": hit["url"],
"type": hit["type"],
"link": build_song_link(song_type, song_id),
"songType": song_type,
"typeName": kind_name(song_type),
"source": "5sing",
}
def song_interval(song_size: Any) -> int:
"""时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式)。"""
return estimate_duration(song_size)
@@ -0,0 +1,73 @@
"""5sing 歌曲搜索(对应 sdk/5sing/musicSearch.ts,上游 fivesing/song.go 的 Search)。
接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;
本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import (
SEARCH_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
def map_search_song(item: dict[str, Any]) -> dict[str, Any]:
"""上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing')。"""
song_type = str(item.get("typeEname") or "")
duration = estimate_duration(item.get("songSize"))
return {
"songmid": f"{item.get('songId', '')}|{song_type}",
"name": remove_em_tags(unescape_html(item.get("songName"))),
"singer": remove_em_tags(unescape_html(item.get("singer") or item.get("nickName"))),
"albumId": "",
"albumName": "",
"interval": format_play_time(duration) if duration > 0 else 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
# 统一种类字段(上游 typeEname / typeName)
"songType": song_type,
"typeName": str(item.get("typeName") or kind_name(song_type)),
"size": int(item.get("songSize") or 0),
"link": build_song_link(song_type, item.get("songId")),
"source": "5sing",
}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "allPage": 0, "total": 0, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=0"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
songs = [map_search_song(item) for item in raw_list[:page_size] if isinstance(item, dict)]
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(songs)
# 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
all_page = int(page_info.get("totalPages") or 0) or (page_no + 1 if len(songs) >= page_size else page_no)
return {"list": songs, "allPage": all_page, "total": total, "limit": page_size, "source": "5sing"}
+25
View File
@@ -0,0 +1,25 @@
"""5sing 链接解析(对应 sdk/5sing/parse.ts)。
歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetch_song_info(直链 + newget 元数据,无直链即报错)
歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId)
专辑: 上游无专辑概念(不实现)
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_song_info
from app.sdk.platforms.fivesing.playlist import parse_playlist_link
from app.sdk.platforms.fivesing.util import extract_song_link
async def parse_song(link: str) -> dict[str, Any]:
parsed = extract_song_link(link)
if not parsed:
raise RuntimeError("invalid 5sing link")
return await fetch_song_info(parsed["songID"], parsed["songType"])
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+282
View File
@@ -0,0 +1,282 @@
"""5sing 歌单(对应 sdk/5sing/playlist.ts,上游 fivesing/fivesing.go + playlist.go)。
搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/...
上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植并发 5。
详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块
③ 数字 userId 页会 302 到个性域名页,fetch_5sing 已手动跟跳。
分类/个人歌单:上游显式返回不支持,本移植保留同样语义(抛错)。
链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名与省略 user 段,ID 以 API 反查为准。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
SEARCH_API,
build_playlist_link,
build_song_link,
encode_uri_component,
extract_playlist_link,
fetch_5sing,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:显式不支持。"""
raise RuntimeError("playlist categories not supported")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:显式不支持。"""
raise RuntimeError("playlist categories not supported")
def _map_playlist_song(song_id: str, kind: str, name: str, artist: str) -> dict[str, Any]:
"""歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状。"""
return {
"songmid": f"{song_id}|{kind}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
"songType": kind,
"typeName": kind_name(kind),
"link": build_song_link(kind, song_id),
"source": "5sing",
}
def parse_songs_from_html(html_content: str) -> list[dict[str, Any]]:
"""上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
块正则 ``<li class="p_rel">([\\s\\S]*?)</li>``;块内 href 提取 yc/fc/bz 与 id,
s_soner 提取歌手(上游的拼写错误 soner 保留)。
"""
blocks = re.findall(r'<li class="p_rel">([\s\S]*?)</li>', str(html_content or ""))
if not blocks:
raise RuntimeError("no songs found in playlist html (structure mismatch)")
# 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
song_re = re.compile(r'href="(?:(?:https?:)?//)?5sing\.kugou\.com/(yc|fc|bz)/(\d+)\.html"[^>]*>([^<]+)</a>')
artist_re = re.compile(r'class="s_soner[^"]*".*?>([^<]+)</a>')
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for block in blocks:
song_match = song_re.search(block)
if not song_match:
continue
kind, song_id, raw_name = song_match.group(1), song_match.group(2), song_match.group(3)
artist = "Unknown"
artist_match = artist_re.search(block)
if artist_match:
artist = artist_match.group(1)
unique_key = f"{kind}|{song_id}"
if unique_key in seen:
continue
seen.add(unique_key)
name = remove_em_tags(unescape_html(raw_name)).strip()
artist = remove_em_tags(unescape_html(artist)).strip()
songs.append(_map_playlist_song(song_id, kind, name, artist))
return songs
async def fetch_creator_name(playlist_id: str) -> str:
"""上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名。"""
if not playlist_id:
return ""
try:
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=user"
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return ""
data = resp.body.get("data")
if not isinstance(data, dict):
return ""
user = as_dict(data.get("user"))
return str(user.get("NN") or "")
except Exception: # noqa: BLE001 - 反查失败保留 ID 兜底
return ""
def _map_playlist_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐)。"""
playlist_id = str(item.get("songListId") or "")
user_id = str(item.get("userId") or "")
desc = remove_em_tags(unescape_html(item.get("content")))
creator = str(item.get("userName") or "") or (f"ID: {user_id}" if user_id else "")
return {
"id": playlist_id,
"name": remove_em_tags(unescape_html(item.get("title"))),
"cover": str(item.get("pictureUrl") or ""),
"trackCount": int(item.get("songCnt") or 0),
"playCount": int(item.get("playCount") or 0),
"creator": creator,
"creatorId": user_id,
# 上游怪癖:content 为字符串 "0" 时视为空简介
"description": "" if desc == "0" else desc,
"link": build_playlist_link(user_id, playlist_id),
"source": "5sing",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist(type=1),补创建者名(并发 5)。"""
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "total": 0, "page": page_no, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=1"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 歌单搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
playlists = [_map_playlist_summary(item) for item in raw_list[:page_size] if isinstance(item, dict)]
# 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
targets = [pl for pl in playlists if pl["creatorId"] and pl["creator"] == f"ID: {pl['creatorId']}"]
for start in range(0, len(targets), 5):
chunk = targets[start : start + 5]
async def fill(pl: dict[str, Any]) -> None:
name = await fetch_creator_name(pl["id"])
if name:
pl["creator"] = name
await asyncio.gather(*(fill(pl) for pl in chunk))
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(playlists)
return {"list": playlists, "total": total, "page": page_no, "limit": page_size, "source": "5sing"}
async def fetch_playlist_all(playlist_id_raw: str) -> dict[str, Any]:
"""元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全)。"""
playlist_id = str(playlist_id_raw or "").strip()
if not playlist_id:
raise RuntimeError("invalid 5sing playlist id")
# 1) API 元数据(标题/封面/统计/UserID)
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=ID,user"
)
if resp.status_code != 200:
raise RuntimeError(f"fetch info failed: HTTP {resp.status_code}")
# 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
body = as_dict(resp.body)
data = body.get("data")
if not isinstance(data, dict):
raise RuntimeError("playlist info not found or invalid (api returned empty list)")
user = as_dict(data.get("user"))
user_id = str(user.get("ID") or "")
if not user_id:
raise RuntimeError("playlist user not found or invalid id")
playlist = {
"id": playlist_id,
"name": str(data.get("T") or ""),
"cover": str(data.get("P") or ""),
"trackCount": int(data.get("E") or 0),
"playCount": int(data.get("H") or 0),
"creator": str(user.get("NN") or ""),
"description": str(data.get("C") or ""),
"link": build_playlist_link(user_id, playlist_id),
"userId": user_id,
"source": "5sing",
}
# 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch_5sing 已跟跳)
page = await fetch_5sing(playlist["link"])
if page.status_code != 200:
raise RuntimeError(f"fetch html failed: HTTP {page.status_code}")
# 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
songs: list[dict[str, Any]] = []
try:
songs = parse_songs_from_html(str(page.body if isinstance(page.body, str) else ""))
except Exception: # noqa: BLE001 - 结构不匹配时返回空列表
songs = []
return {"playlist": playlist, "songs": songs}
def _playlist_info(playlist: dict[str, Any]) -> dict[str, Any]:
return {
"id": playlist["id"],
"name": playlist["name"],
"img": playlist["cover"],
"desc": playlist["description"],
"author": playlist["creator"],
"link": playlist["link"],
"trackCount": playlist["trackCount"],
"playCount": playlist["playCount"],
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E)。"""
detail = await fetch_playlist_all(playlist_id)
playlist, songs = detail["playlist"], detail["songs"]
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
start = (page_no - 1) * page_size
total = len(songs)
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs[start : start + page_size],
"page": page_no,
"limit": page_size,
"total": total,
"allPage": math.ceil(total / page_size) or 1,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:仅返回曲目列表(全量)。"""
detail = await fetch_playlist_all(playlist_id)
return detail["songs"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(全量曲目)。"""
parsed = extract_playlist_link(link)
if not parsed:
raise RuntimeError("invalid 5sing playlist link")
detail = await fetch_playlist_all(parsed["playlistId"])
playlist, songs = detail["playlist"], detail["songs"]
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs,
"page": 1,
"limit": len(songs) or 1,
"total": len(songs),
"allPage": 1,
}
+196
View File
@@ -0,0 +1,196 @@
"""5sing 公共工具(对应 sdk/5sing/util.ts,上游 fivesing/fivesing.go 的等价实现)。
平台 id 统一为 ``5sing``(上游 Go 包名 fivesing,model.Source 用 "fivesing";本仓库契约要求 5sing)。
上游 ``New(cookie)`` 的 cookie 实际参与请求(搜索 / getSongUrl / newget / 歌单页),
这里从 auth_store 的 ``5sing`` 记录读取(无则匿名)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
UserAgent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
)
#: 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302)
MOBILE_API = "http://mobileapi.5sing.kugou.com"
#: 搜索接口(type=0 单曲 / type=1 歌单)
SEARCH_API = "http://search.5sing.kugou.com/home/json"
#: 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏)
SONG_KINDS: dict[str, str] = {"yc": "原创", "fc": "翻唱", "bz": "伴奏"}
HTML_ENTITIES: dict[str, str] = {
"amp": "&",
"lt": "<",
"gt": ">",
"quot": '"',
"apos": "'",
"nbsp": " ",
"copy": "©",
"reg": "®",
"hellip": "…",
"mdash": "—",
"ndash": "–",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def get_first_valid(*urls: Any) -> str:
"""上游 getFirstValid:返回第一个非空字符串(空串视为无效)。"""
for url in urls:
if url:
return str(url)
return ""
def remove_em_tags(text: Any) -> str:
"""去掉搜索结果里 ``<em class="keyword">`` 高亮标签。"""
return str(text if text is not None else "").replace('<em class="keyword">', "").replace("</em>", "").strip()
def unescape_html(text: Any) -> str:
"""Go html.UnescapeString 的等价实现(常用实体 + 数字实体)。"""
source = str(text if text is not None else "")
def repl(match: re.Match[str]) -> str:
entity = match.group(1)
try:
if entity.startswith(("#x", "#X")):
return chr(int(entity[2:], 16))
if entity.startswith("#"):
return chr(int(entity[1:], 10))
except ValueError:
return match.group(0)
return HTML_ENTITIES.get(entity.lower(), match.group(0))
return re.sub(r"&(#x?[0-9a-fA-F]+|[a-zA-Z]+);", repl, source)
def get_5sing_cookie() -> str:
"""上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用)。"""
cred = get_credentials("5sing") or {}
return str(cred.get("cookie") or "")
def get_status() -> dict[str, Any]:
"""登录态(凭据存在性判断)。"""
cred = get_credentials("5sing") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
def is_foreign_source(song_info: dict[str, Any]) -> bool:
"""是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查)。"""
source = song_info.get("source")
return bool(source) and source != "5sing"
async def fetch_5sing(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""带 Cookie/UA 的请求;httpFetch 不跟随 302,这里手动跟跳最多 3 次。
5sing 老链接会 http→https / 数字 userId → 个性域名跳转,必须跟。
"""
cookie = get_5sing_cookie()
all_headers = {"User-Agent": UserAgent}
if cookie:
all_headers["Cookie"] = cookie
if headers:
all_headers.update({str(key): str(value) for key, value in headers.items()})
current = str(url)
response = None
for _hop in range(4):
response = await http_fetch(current, headers=all_headers, timeout=timeout_ms)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
from urllib.parse import urljoin
try:
current = urljoin(current, location)
except ValueError:
return response
continue
return response
return response
async def fetch_5sing_json(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""JSON 接口带退避重试(手机端 API 频率控制:code 13 / "操作太频繁")。"""
response = None
for attempt in range(3):
response = await fetch_5sing(url, headers=headers, timeout_ms=timeout_ms)
body = response.body if isinstance(response.body, dict) else {}
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
message = str(body.get("message") or "")
if (code == 13 or "太频繁" in message) and attempt < 2:
await asyncio.sleep(0.8 * (attempt + 1))
continue
return response
return response
def build_song_link(song_type: str, song_id: Any) -> str:
"""歌曲页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{song_type}/{song_id}.html" if song_type and song_id else ""
def build_playlist_link(user_id: Any, playlist_id: str) -> str:
"""歌单页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{user_id}/dj/{playlist_id}.html" if user_id and playlist_id else ""
def extract_song_id(songmid: Any, song_type_fallback: Any = None) -> dict[str, str]:
"""解析歌曲的 ``<songid>|<songtype>`` 约定(兼容只给 songType 字段的调用)。"""
raw = str(songmid if songmid is not None else "")
parts = raw.split("|")
id_part = parts[0] if parts else ""
type_part = parts[1] if len(parts) > 1 else ""
return {"songID": id_part or "", "songType": str(type_part or song_type_fallback or "")}
def extract_song_link(link: Any) -> dict[str, str] | None:
"""上游 Parse 的正则:5sing.kugou.com/(\\w+)/(\\d+).html(专辑/歌手页不匹配)。"""
match = re.search(r"5sing\.kugou\.com/(\w+)/(\d+)\.html", str(link if link is not None else ""))
if not match:
return None
return {"songType": match.group(1), "songID": match.group(2)}
def extract_playlist_link(link: Any) -> dict[str, str] | None:
"""歌单链接解析(放宽为任意单段 userId,含个性域名与省略 user 段)。"""
match = re.search(
r"5sing\.kugou\.com/(?:([^/?#]+)/)?dj/([a-zA-Z0-9]+)\.html", str(link if link is not None else "")
)
if not match:
return None
return {"userId": match.group(1) or "", "playlistId": match.group(2)}
def estimate_duration(song_size: Any) -> int:
"""上游 Search 的时长估算:songSize*8/320000(320kbps 假设;0 返回 0)。"""
try:
size = float(song_size or 0)
except (TypeError, ValueError):
size = 0.0
return math.trunc((size * 8) / 320000) if size > 0 else 0
def kind_name(song_type: str) -> str:
"""作品类型中文名(typeName 缺失时兜底)。"""
return SONG_KINDS.get(song_type, "")
+85
View File
@@ -0,0 +1,85 @@
"""JOOX(joox)平台模块(对应 sdk/joox/index.ts)。
匿名可用面(2026-10 实测,www 站点需 X-Forwarded-For 东南亚 IP 绕过地区拦截):
搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+链接解析)/ 歌词 / 链接解析(歌曲)/
取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
通道:
* 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(**无签名**)
* 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(**需 md5 签名**,见 util.joox_sign)
* 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
* 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
⚠ 网络连通性实况(本机出口 IP 非东南亚):
* cache.api.joox.com 与 api.joox.com 的 JSON 接口全部可直连;
* www.joox.com 的 SSR 页面返回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__,
因此 ``playlist.get_categories`` / ``playlist.get_category_playlists`` 与专辑·歌单的页面兜底
在当前网络下会如实抛错(不返回假数据)。这与 TS 注释一致:TS 也依赖伪造 X-Forwarded-For,
本移植带的是同一个头(``util.JOOX_XFF``),能否生效取决于上游是否采信,实测本机未被采信。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.apis.joox import get_music_url
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.joox import album, lyric, music_search, parse, playlist
from app.sdk.platforms.joox.music_info import get_track_detail
from app.sdk.platforms.joox.util import joox_song_link, normalize_joox_id, pick_joox_image
class JOOXSongList:
"""契约别名 ``songList``(对应 TS index.ts 里的内联 shim)。
JOOX 没有独立的「排行榜/歌曲列表」接口:``getTags`` 即歌单分类,``getList`` 即某分类下的歌单。
``sort_list`` 只有「全部」一项,因此路由透传的 ``sort_id`` 恒为空串,``tag_id`` 才是分类号。
TS 的 ``getList(sortId, tagId, page, limit=30)`` 中 ``limit`` 固定在内部,路由不透传。
"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "全部", "id": ""}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: str = "", tag_id: str = "", page: int = 1) -> dict[str, Any]:
return await playlist.get_category_playlists(str(tag_id or sort_id or ""), page, 30)
async def _get_pic(song_info: dict[str, Any]) -> str | None:
"""封面(对应 TS index.ts 的 getPic):走 openjoox2 track 详情后挑图,取不到返回 None。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or "")
detail = await get_track_detail(song_id)
return pick_joox_image(detail.get("images")) or None
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return joox_song_link(song_info.get("songmid") or song_info.get("id") or "")
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断;无在线校验接口)。"""
cred = get_credentials("joox")
if cred and cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="joox",
name="JOOX",
music_search=music_search,
song_list=JOOXSongList,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=_get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
View File
Whitespace-only changes.
+221
View File
@@ -0,0 +1,221 @@
"""JOOX 专辑(对应 sdk/joox/album.ts,上游 joox/album.go 移植 + 新版 openjoox2 通道)。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``section_type=1`` 专辑区,``item.type=2``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
(web 播放器通道,需 md5 签名;实测 2026-10 HTTP 200)
兜底:www.joox.com/<region>/album/<id> SSR ``__NEXT_DATA__.props.pageProps.albumData``
⚠ 该兜底依赖 X-Forwarded-For 伪装的东南亚 IP;本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此该路径在当前网络环境下不可用,仅在 openjoox2 失败时尝试,失败即如实抛出。
链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
路径里的 album id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径,
故路径写法不影响 secret 校验。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_album_link,
joox_artists,
joox_headers,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
to_int,
)
async def fetch_album_tracks(album_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 专辑曲目(含专辑元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/album/{album_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")), album_id)
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), album_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": joox_artists(body.get("artist_list")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def fetch_album_page_data(album_id: str) -> dict[str, Any]:
"""SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分)。"""
page = await fetch_joox_page(joox_album_link(album_id))
next_data = extract_joox_next_data(page["body"])
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
album_data = page_props.get("albumData")
if isinstance(album_data, dict) and normalize_joox_id(album_data.get("id")):
return album_data
nested = ((page_props.get("content") or {}).get("page") or {}).get("albumData")
if isinstance(nested, dict) and normalize_joox_id(nested.get("id")):
return nested
raise RuntimeError("joox 专辑页数据不存在")
async def get_album_detail_from_page(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径)。"""
album_id = normalize_joox_id(album_id)
if not album_id:
raise RuntimeError("joox 专辑 id 无效")
album_data = await fetch_album_page_data(album_id)
track_list = album_data.get("trackList")
items = track_list.get("items") if isinstance(track_list, dict) else None
items = items if isinstance(items, list) else []
page_start = max(0, (page - 1) * limit)
page_items = items[page_start : page_start + limit]
cover = str(first_non_empty(pick_joox_image(album_data.get("images")), album_data.get("imgSrc")))
songs = [
song
for song in (
joox_song_from_track(item, str(album_data.get("title") or ""), cover, album_id) for item in page_items
)
if song
]
if not songs:
raise RuntimeError("joox 专辑无歌曲")
total = first_non_zero(
track_list.get("total_count") if isinstance(track_list, dict) else None,
track_list.get("list_count") if isinstance(track_list, dict) else None,
len(items),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(album_data.get("title"), album_id)),
"img": cover or None,
"desc": str(first_non_empty(album_data.get("description"), album_data.get("publishDate"))),
"author": joox_artists(album_data.get("artistList")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(openjoox2 优先,SSR 页兜底)。"""
normalized = normalize_joox_id(album_id)
if not normalized:
raise RuntimeError("joox 专辑 id 无效")
try:
detail = await fetch_album_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走页面兜底
pass
return await get_album_detail_from_page(normalized, page, limit)
async def get_album_songs(album_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""专辑曲目(上游 GetAlbumSongs:仅曲目列表)。"""
detail = await get_album_detail(album_id, page, limit)
return detail["list"]
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
albums: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict) or to_int(section.get("section_type")) != 1:
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or to_int(item.get("type")) != 2:
continue
raw_album = item.get("album")
album = raw_album if isinstance(raw_album, dict) else {}
album_id = normalize_joox_id(album.get("id"))
if not album_id or album_id in seen:
continue
seen.add(album_id)
publish_date = str(album.get("publish_date") or "").strip()
albums.append(
{
"id": album_id,
"name": str(album.get("name") or ""),
"cover": pick_joox_image(album.get("images")) or None,
"creator": joox_artists(album.get("artist_list")),
"description": publish_date,
"publishDate": publish_date,
"trackCount": 0,
"link": joox_album_link(album_id),
"source": "joox",
}
)
return {"list": albums[:limit], "total": len(albums), "page": page, "limit": limit, "source": "joox"}
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/album/([^/?#]+)",
r"h_activity_id=([^&]+)",
r"albumid=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 10 and "/" not in stripped:
return await get_album_detail(stripped)
raise RuntimeError("invalid joox album link")
+92
View File
@@ -0,0 +1,92 @@
"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。
主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
响应形如 ``MusicJsonCallback({"code":0,"lyric":"<base64>"})``,lyric 为 base64 UTF-8 歌词。
实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。
兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。
解码:base64 → UTF-8 → HTML 实体(&amp; 等)→ ``{ lyric, tlyric: "" }``。
QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。
"""
from __future__ import annotations
import json
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_lyric,
encode_uri_component,
first_non_empty,
joox_headers,
normalize_joox_id,
)
LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric"
def parse_lyric_payload(body: Any) -> dict[str, Any] | None:
"""解析 MusicJsonCallback(...) / 裸 JSON 包裹。"""
if isinstance(body, dict):
return body
text = str(body if body is not None else "")
index = text.find("MusicJsonCallback(")
if index >= 0:
text = text[index + len("MusicJsonCallback(") :]
text = text.rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
if text.endswith(")"):
text = text[:-1].rstrip()
try:
parsed = json.loads(text)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def parse_legacy_lyric(status_code: int, body: Any) -> str:
"""上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。"""
if status_code != 200:
raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})")
payload = parse_lyric_payload(body)
if not payload:
raise RuntimeError("joox 歌词解析失败")
raw = str(first_non_empty(payload.get("lyric"), ""))
if int(payload.get("code") or 0) != 0 or not raw:
raise RuntimeError("joox 歌词不存在或为空")
return decode_joox_lyric(raw)
async def get_legacy_lyric(song_id: str) -> str:
"""上游 GetLyrics 通道(仅旧接口,测试/内部用)。"""
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return parse_legacy_lyric(res.status_code, res.body)
async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]:
"""歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
# 1) 上游 web_lyric 通道
legacy_err: Exception | None = None
try:
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""}
except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底
legacy_err = err
# 2) 兜底:openjoox2 详情 lrc_content(base64)
res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body if isinstance(res.body, dict) else {}
raw = str(first_non_empty(body.get("lrc_content"), ""))
if res.status_code != 200 or not raw:
raise legacy_err or RuntimeError("joox 歌词不存在或为空")
return {"lyric": decode_joox_lyric(raw), "tlyric": ""}
+103
View File
@@ -0,0 +1,103 @@
"""JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)。
对应 sdk/joox/musicInfo.ts。
端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
* 参数按**插入序**参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
* 签名串与真正发出的参数来自同一次序列化(同一份 params dict + 同一 encoder),不做二次拼接。
* 实测(2026-10):不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};
按上式 → 200,返回 play_url_list / refrain_url / lrc_content / qrc_content / vip_flag 等。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 前),本模块为其替代。
``fs=1`` 请求完整曲目(游客仍可能拿到 preview/refrain),``im`` 为 mobile 标记。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
cookie_value,
encode_uri_component,
joox_headers,
joox_login_cookie,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
)
JOOX_OPENJOOX2 = "https://cache.api.joox.com/openjoox2/v1"
def build_track_url(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> str:
"""构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk)。"""
track_id = str(normalize_joox_id(raw_id)).replace("/", "_")
params: dict[str, Any] = {
"country": country or "hk",
"lang": normalize_joox_lang(lang or "zh_TW"),
"lyric": 1,
"fs": 1 if full_song else 0,
"im": 1 if is_mobile else 0,
}
# 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
login_cookie = joox_login_cookie()
uid = cookie_value(login_cookie, "wmid") if login_cookie else ""
usk = cookie_value(login_cookie, "session_key") if login_cookie else ""
if uid:
params["uid"] = uid
if usk:
params["usk"] = usk
# 签名时包含 id(与 web 播放器一致),发布 query 时 id 走路径、不进 query
secret = joox_sign({**params, "id": track_id})
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in {**params, "secret": secret}.items())
return f"{JOOX_OPENJOOX2}/track/{track_id}?{query}"
async def get_track_detail(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> dict[str, Any]:
"""歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现)。"""
track_id = normalize_joox_id(raw_id)
if not track_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(track_id.replace("/", "_"), country, lang, full_song, is_mobile)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 歌曲详情获取失败:{msg or f'HTTP {res.status_code}'}")
return body
def pick_track_url(detail: Any) -> str:
"""从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 &amp; 实体。"""
if not isinstance(detail, dict):
return ""
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = detail.get("play_url_list")
for url in urls if isinstance(urls, list) else []:
text = clean(url)
if text.startswith("http"):
return text
refrain = clean(detail.get("refrain_url"))
return refrain if refrain.startswith("http") else ""
+57
View File
@@ -0,0 +1,57 @@
"""JOOX 搜索(对应 sdk/joox/musicSearch.ts,上游 joox/song.go Search 移植)。
端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
返回 ``section_list``(0 歌曲 / 1 专辑 / 2 歌手 / 3 歌单 / 5 最佳结果 / 7 用户),
歌曲在 ``item_list[].song[].song_info``。
该接口**无分页参数**(上游也未分页),``page`` 仅为统一契约保留;``allPage`` 恒为 1,
``total`` 是去重后的本次结果条数(不是上游分页总数)。
实测(2026-10):中文/英文关键词均 HTTP 200。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.song import joox_song_from_search
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, encode_uri_component, joox_headers
SEARCH_URL = "https://cache.api.joox.com/openjoox/v3/search"
def build_search_url(keyword: str) -> str:
return f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(keyword or '')}"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索歌曲。``page`` 不参与请求(上游无分页),保留以对齐契约。"""
url = build_search_url(str(keyword or ""))
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "allPage": 0, "total": 0, "limit": limit, "source": "joox"}
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict):
continue
for entry in item.get("song") or []:
info = entry.get("song_info") if isinstance(entry, dict) else None
song = joox_song_from_search(info)
if not song or song["songmid"] in seen:
continue
seen.add(song["songmid"])
songs.append(song)
return {
"list": songs[:limit],
"allPage": 1,
"total": len(songs),
"limit": limit,
"source": "joox",
}
+43
View File
@@ -0,0 +1,43 @@
"""JOOX 链接解析(对应 sdk/joox/parse.ts)。
歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.get_album_detail
歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.get_playlist_detail
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.joox.album import parse_album as _parse_album
from app.sdk.platforms.joox.music_info import get_track_detail, pick_track_url
from app.sdk.platforms.joox.playlist import parse_playlist as _parse_playlist
from app.sdk.platforms.joox.song import joox_song_from_detail
from app.sdk.platforms.joox.util import normalize_joox_id
_SINGLE_RE = re.compile(r"joox\.com/.*/single/([^/?#]+)")
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则)。"""
raw = str(link or "").strip()
match = _SINGLE_RE.search(raw)
if match:
song_id = normalize_joox_id(match.group(1))
elif len(raw) > 10 and "/" not in raw:
song_id = normalize_joox_id(raw)
else:
raise RuntimeError("invalid joox link")
detail = await get_track_detail(song_id)
return joox_song_from_detail(detail, pick_track_url(detail))
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接 → 专辑详情(上游 ParseAlbum)。"""
return await _parse_album(link)
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接 → 歌单详情(上游 ParsePlaylist)。"""
return await _parse_playlist(link)
+420
View File
@@ -0,0 +1,420 @@
"""JOOX 歌单(对应 sdk/joox/playlist.ts,上游 joox/playlist.go 移植)。
两条路:OpenJOOX 接口 + 网页数据兜底。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``item.type=1`` → ``editor_playlist``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret=
(上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR ``__NEXT_DATA__`` 的
props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
分类:www.joox.com/sg/playlist(302 → /<region> 首页)``__NEXT_DATA__.props.pageProps.mlList``
⚠ 分类与页面兜底都依赖 X-Forwarded-For 伪装的东南亚 IP。本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此 get_categories / get_category_playlists 在当前网络环境下会如实抛错,不返回假数据。
链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID。
路径里的歌单 id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_base64_text,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_headers,
joox_playlist_link,
joox_playlist_links,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
)
CATEGORY_PAGE = "https://www.joox.com/sg/playlist"
def map_playlist_summary(item: Any) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
playlist_id = normalize_joox_id(item.get("id"))
if not playlist_id:
return None
return {
"id": playlist_id,
"name": str(item.get("name") or ""),
"cover": pick_joox_image(item.get("images")) or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(playlist_id),
"source": "joox",
}
async def fetch_playlist_tracks(playlist_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 歌单曲目(含歌单元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/playlist/{playlist_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")))
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
body.get("track_count"),
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), playlist_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": "",
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
def _track_items(tracks: Any) -> list[Any]:
if not isinstance(tracks, dict):
return []
items = tracks.get("items")
return items if isinstance(items, list) else []
def _track_total(*candidates: Any) -> Any:
return first_non_zero(*candidates)
def _nested_name(detail: dict[str, Any], key: str) -> Any:
"""TS 里 ``detail?.user?.name`` 这类嵌套取值的兑底链(name → userName → nickName → nick)。"""
node = detail.get(key)
if not isinstance(node, dict):
return None
return first_non_empty(node.get("name"), node.get("userName"), node.get("nickName"), node.get("nick"))
def parse_playlist_page_data(next_data: Any, page_url: str) -> dict[str, Any] | None:
"""网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植)。"""
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
detail: dict[str, Any] = page_props.get("playlistDetailList") or {}
tracks = _track_items((page_props.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
nested = (page_props.get("content") or {}).get("page") or {}
detail = nested.get("playlistDetailList") or {}
tracks = _track_items((nested.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
track_count,
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
return None
playlist_id = normalize_joox_id(first_non_empty(detail.get("id"), detail.get("listId"), detail.get("playlistId")))
name = str(first_non_empty(detail.get("name"), detail.get("title"), detail.get("playlistName"), playlist_id))
cover = str(first_non_empty(detail.get("imgSrc"), detail.get("cover"), detail.get("image"), detail.get("pic")))
creator = first_non_empty(
detail.get("creator"),
detail.get("creatorName"),
detail.get("userName"),
detail.get("nickName"),
detail.get("ownerName"),
detail.get("author"),
detail.get("authorName"),
_nested_name(detail, "user"),
_nested_name(detail, "owner"),
_nested_name(detail, "creatorInfo"),
)
description = first_non_empty(detail.get("description"), detail.get("intro"), detail.get("desc"))
return {
"id": playlist_id,
"name": name,
"cover": cover,
"creator": creator,
"description": description,
"trackCount": track_count,
"tracks": tracks,
"pageUrl": page_url,
}
async def get_playlist_detail_from_page(raw_id: str) -> dict[str, Any]:
"""网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径)。"""
playlist_id = normalize_joox_id(raw_id)
if not playlist_id:
raise RuntimeError("joox 歌单 id 无效")
last_err: Exception | None = None
for page_url in joox_playlist_links(playlist_id):
try:
page = await fetch_joox_page(page_url)
parsed = parse_playlist_page_data(extract_joox_next_data(page["body"]), page_url)
if not parsed:
raise RuntimeError("joox 歌单页数据不存在")
page_id = normalize_joox_id(parsed["id"]) or playlist_id
cover = str(parsed["cover"] or "")
songs = [
song
for song in (joox_song_from_track(item, parsed["name"], cover) for item in parsed["tracks"])
if song
]
if not songs:
raise RuntimeError("joox 歌单无歌曲")
return {
"source": "joox",
"info": {
"name": str(parsed["name"] or page_id),
"img": cover or None,
"desc": str(parsed["description"] or ""),
"author": str(parsed["creator"] or ""),
},
"list": songs,
"page": 1,
"limit": len(songs),
"total": first_non_zero(parsed["trackCount"], len(songs)),
}
except Exception as err: # noqa: BLE001 - 逐个区域尝试,全部失败才抛最后一个错误
last_err = err
raise last_err or RuntimeError("joox 歌单页数据不存在")
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留)。"""
normalized = normalize_joox_id(playlist_id)
if not normalized:
raise RuntimeError("joox 歌单 id 无效")
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走网页兜底
pass
return await get_playlist_detail_from_page(normalized)
async def get_playlist_songs(playlist_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底)。"""
normalized = normalize_joox_id(playlist_id)
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail["list"]
except Exception: # noqa: BLE001 - 走网页兜底
pass
from_page = await get_playlist_detail_from_page(normalized)
return from_page["list"]
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(上游 SearchPlaylist)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
playlists: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or _as_int(item.get("type")) != 1:
continue
summary = map_playlist_summary(item.get("editor_playlist"))
if not summary or summary["id"] in seen:
continue
seen.add(summary["id"])
playlists.append(summary)
return {"list": playlists[:limit], "total": len(playlists), "page": page, "limit": limit, "source": "joox"}
async def fetch_playlist_categories_page() -> dict[str, Any]:
"""歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage)。"""
page = await fetch_joox_page(CATEGORY_PAGE)
next_data = extract_joox_next_data(page["body"])
ml_list = ((next_data or {}).get("props") or {}).get("pageProps", {})
ml_list = (ml_list or {}).get("mlList")
if not isinstance(ml_list, dict) or not ml_list.get("category"):
raise RuntimeError("joox 歌单分类不存在")
return ml_list
def category_playlist_id(value: Any) -> str:
"""分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一)。"""
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(int(value))
return normalize_joox_id(value)
def _categories(ml_list: dict[str, Any]) -> list[Any]:
categories = ml_list.get("category")
return categories if isinstance(categories, list) else []
async def get_categories() -> dict[str, Any]:
"""分类列表(上游 GetPlaylistCategories;首个为「全部」)。"""
data = await fetch_playlist_categories_page()
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部", "hot": False}]
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
entries = category.get("itemlist")
entries = entries if isinstance(entries, list) else []
if not name or not entries:
continue
items.append({"id": str(index), "name": name, "group": "JOOX", "hot": False, "count": len(entries)})
if len(items) == 1:
raise RuntimeError("joox 歌单分类不存在")
return {"source": "joox", "list": items}
async def get_category_playlists(category_id: str = "", page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类下歌单(上游 GetCategoryPlaylists)。"""
data = await fetch_playlist_categories_page()
cid = str(category_id or "").strip()
items: list[Any] = []
category_name = "全部"
if not cid:
seen: set[str] = set()
for category in _categories(data):
if not isinstance(category, dict):
continue
for item in category.get("itemlist") or []:
entry_id = category_playlist_id(item.get("id") if isinstance(item, dict) else None)
if not entry_id or entry_id in seen:
continue
seen.add(entry_id)
items.append(item)
else:
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
if str(index) == cid or (name and name.lower() == cid.lower()):
entries = category.get("itemlist")
items = entries if isinstance(entries, list) else []
category_name = name
break
if not items:
raise RuntimeError("joox 歌单分类不存在")
start = max(0, (page - 1) * limit)
if start >= len(items):
raise RuntimeError("joox 分类下无歌单")
page_items = items[start : start + limit]
playlists: list[dict[str, Any]] = []
for item in page_items:
if not isinstance(item, dict):
continue
entry_id = category_playlist_id(item.get("id"))
name = decode_joox_base64_text(item.get("title"))
if not entry_id or not name:
continue
playlists.append(
{
"id": entry_id,
"name": name,
"cover": str(item.get("picurl") or "").replace("%d", "300") or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(entry_id),
"categoryId": cid,
"categoryName": category_name,
"source": "joox",
}
)
if not playlists:
raise RuntimeError("joox 分类下无歌单")
return {"list": playlists, "total": len(items), "page": page, "limit": limit, "source": "joox"}
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接解析(上游 ParsePlaylist)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/playlist/([^/?#]+)",
r"(?:playlistid|playlist_id|id)=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_playlist_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 8 and "/" not in stripped:
return await get_playlist_detail(stripped)
raise RuntimeError("invalid joox playlist link")
def _as_int(value: Any) -> int | None:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
+96
View File
@@ -0,0 +1,96 @@
"""JOOX 歌曲形状映射(对应 sdk/joox/song.ts)。
统一 Song 形状(见 tmp/port-spec.md);兼容来源:openjoox2 track/album/playlist 条目、
openjoox v3 search 的 song_info、以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,
上游 Go 也两套都兜底)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.joox.util import (
first_non_empty,
first_non_zero,
joox_artists,
normalize_joox_id,
pick_joox_image,
to_int,
)
def joox_song_from_track(
item: Any,
fallback_album: str = "",
fallback_cover: str = "",
fallback_album_id: str = "",
) -> dict[str, Any] | None:
"""详情/列表 track 条目 → 统一 Song 形状。"""
if not isinstance(item, dict):
item = {}
songmid = normalize_joox_id(item.get("id"))
if not songmid:
return None
artists = item.get("artist_list")
if not (isinstance(artists, list) and artists):
artists = item.get("artistList")
album_id = normalize_joox_id(first_non_empty(item.get("album_id"), item.get("albumId"), fallback_album_id))
return {
"songmid": songmid,
"name": str(first_non_empty(item.get("name"), item.get("title"), "Unknown")),
"singer": str(first_non_empty(joox_artists(artists), "Unknown")),
"albumId": str(album_id or ""),
"albumName": str(first_non_empty(item.get("album_name"), item.get("albumName"), fallback_album)),
"interval": format_play_time(
first_non_zero(item.get("play_duration"), item.get("playDuration"), item.get("duration"))
),
"img": str(
first_non_empty(
pick_joox_image(item.get("images")),
item.get("imgSrc"),
item.get("cover"),
item.get("image"),
item.get("pic"),
fallback_cover,
)
)
or None,
"lrc": None,
"vip": to_int(item.get("vip_flag") if item.get("vip_flag") is not None else 0) == 1,
"types": [],
"_types": {},
"source": "joox",
}
def joox_song_from_search(info: Any) -> dict[str, Any] | None:
"""openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状。"""
return joox_song_from_track(info)
def joox_song_from_detail(detail: Any, play_url: str = "") -> dict[str, Any]:
"""详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状。"""
if not isinstance(detail, dict):
detail = {}
song = joox_song_from_track(detail)
if song is None:
song = {
"songmid": normalize_joox_id(detail.get("id")),
"name": str(detail.get("name") or ""),
"singer": str(joox_artists(detail.get("artist_list"))),
"albumId": str(normalize_joox_id(detail.get("album_id"))),
"albumName": str(detail.get("album_name") or ""),
"interval": format_play_time(first_non_zero(detail.get("play_duration"))),
"img": str(pick_joox_image(detail.get("images"))) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "joox",
}
if play_url:
song["url"] = play_url
song["vip"] = to_int(detail.get("vip_flag") if detail.get("vip_flag") is not None else 0) == 1
return song
+267
View File
@@ -0,0 +1,267 @@
"""JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取(对应 sdk/joox/util.ts)。
背景(与 TS 注释一致,2026-10 实测):
* JOOX 是东南亚服务,www.joox.com 会按来源 IP 返回「Sorry, JOOX is not available in this
location」;上游通过伪造 ``X-Forwarded-For`` 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
* 新版 web 播放器走 ``cache.api.joox.com/openjoox2/v1/*``,**必须**带 secret 签名:
secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent)
密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
实测:不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};按上式 → 200。
* 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
但 web_lyric 仍可用(歌词首选通道)。
* 登录 cookie 里的 wmid/session_key 在签名参数里作为 uid/usk 参与(VIP 完整曲目依赖它)。
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
JOOX_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
"""上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie)。"""
JOOX_DEFAULT_COOKIE = "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;"
"""上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区拦截)。"""
JOOX_XFF = "36.73.34.109"
"""openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey)。"""
JOOX_SECRET_KEY = "Jo0x@t3Nc3nT"
"""平台统一超时(毫秒),与 TS 各处 timeout: 20000 一致。"""
JOOX_TIMEOUT = 20000
def md5_hex(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def joox_sign(params: dict[str, Any]) -> str:
"""openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)。
**键序即 dict 插入序**(服务端按同序校验),不要重排参数 —— 任何 sorted() 都会破坏签名。
"""
pairs = [f"{key}={encode_uri_component(value)}" for key, value in params.items()]
return md5_hex(JOOX_SECRET_KEY + "&".join(pairs))
def joox_login_cookie() -> str:
"""绑定到 auth_store 的登录 cookie(未登录返回空串,不回退游客 cookie)。"""
cred = get_credentials("joox")
if not cred:
return ""
return str(cred.get("cookie") or "")
def joox_cookie() -> str:
"""请求用 cookie(登录态优先;未登录回退上游游客 cookie)。"""
return joox_login_cookie() or JOOX_DEFAULT_COOKIE
def cookie_value(cookie: str, name: str) -> str:
"""从 cookie 串里取一个字段。"""
match = re.search(rf"(?:^|;\s*){re.escape(name)}=([^;]*)", str(cookie or ""))
return match.group(1).strip() if match else ""
def joox_headers(extra: dict[str, Any] | None = None) -> dict[str, str]:
"""平台公共请求头(含地区伪装与 Cookie)。"""
headers = {
"User-Agent": JOOX_UA,
"Cookie": joox_cookie(),
"X-Forwarded-For": JOOX_XFF,
"Referer": "https://www.joox.com/",
"Accept": "application/json, text/plain, */*",
}
if extra:
headers.update({str(key): str(value) for key, value in extra.items()})
return headers
def normalize_joox_lang(lang: str | None) -> str:
"""语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN)。"""
if lang in ("zh_hk", "zh_mo"):
return "zh_TW"
if lang == "zh_cn":
return "zh_CN"
return lang or "zh_TW"
def normalize_joox_id(raw: Any) -> str:
"""ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +)。"""
normalized = str(raw if raw is not None else "").strip()
if not normalized:
return ""
if "%" in normalized:
try:
normalized = unquote(normalized, errors="strict")
except (UnicodeDecodeError, ValueError):
# 上游 PathUnescape/QueryUnescape 失败时保留原值
pass
normalized = normalized.replace(" ", "+")
return normalized
def joox_song_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/single/{normalize_joox_id(raw_id)}"
def joox_album_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/album/{normalize_joox_id(raw_id)}"
def joox_regional_playlist_link(region: str, raw_id: Any) -> str:
return f"https://www.joox.com/{region}/playlist/{normalize_joox_id(raw_id)}"
def joox_playlist_link(raw_id: Any) -> str:
return joox_regional_playlist_link("id", raw_id)
def joox_playlist_links(raw_id: Any) -> list[str]:
"""歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用)。"""
return [joox_regional_playlist_link(region, raw_id) for region in ("id", "sg", "hk")]
def pick_joox_image(images: Any) -> str:
"""封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空)。"""
items = images if isinstance(images, list) else []
for width in (300, 1000, 100):
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if to_int(image.get("width")) == width and url:
return url
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if url:
return url
return ""
def joox_artists(items: Any) -> str:
"""歌手拼接(上游 joinJooxArtists,分隔符 ' / ')。"""
listing = items if isinstance(items, list) else []
names = [str(entry.get("name") or "").strip() for entry in listing if isinstance(entry, dict)]
return " / ".join(name for name in names if name)
def first_non_empty(*values: Any) -> Any:
for value in values:
if value is None:
continue
if str(value).strip() != "":
return value
return ""
def first_non_zero(*values: Any) -> int | float:
for value in values:
number = to_number(value)
if number is not None and number > 0:
return number
return 0
def to_number(value: Any) -> float | None:
"""对齐 JS Number():有限数值返回 float,其余返回 None。"""
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return 0.0
try:
number = float(text)
except ValueError:
return None
else:
return None
if number != number or number in (float("inf"), float("-inf")):
return None
return number
def to_int(value: Any) -> int | None:
number = to_number(value)
return None if number is None else int(number)
def decode_joox_base64_text(value: Any) -> str:
"""base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充)。"""
raw = str(value if value is not None else "").strip()
if not raw:
return ""
normalized = raw.replace("-", "+").replace("_", "/")
padded = normalized + "=" * ((4 - len(normalized) % 4) % 4)
try:
text = base64.b64decode(padded, validate=False).decode("utf-8", errors="replace")
except (ValueError, TypeError):
return raw
# 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
return raw if "\ufffd" in text else text.strip()
def decode_html_entities(text: Any) -> str:
"""HTML 实体解码(歌词文本兜底)。"""
return (
str(text if text is not None else "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&apos;", "'")
.replace("&#0*39;", "'")
.replace("&nbsp;", " ")
)
def decode_joox_lyric(value: Any) -> str:
"""歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底)。"""
return decode_html_entities(decode_joox_base64_text(value))
_NEXT_DATA_RE = re.compile(r'<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)</script>')
def extract_joox_next_data(html: Any) -> Any:
"""从 SSR 页面提取 __NEXT_DATA__ JSON。"""
match = _NEXT_DATA_RE.search(str(html if html is not None else ""))
if not match:
return None
try:
return json.loads(match.group(1))
except ValueError:
return None
async def fetch_joox_page(url: str, max_redirect: int = 3) -> dict[str, Any]:
"""拉取页面并手动跟随 3xx(request.py 不自动跟跳;Go 客户端会自动跟)。
返回 ``{"statusCode", "body", "finalUrl"}``(非字符串 body 视为空串)。
"""
current = url
for _ in range(max_redirect + 1):
res = await http_fetch(current, headers=joox_headers(), timeout=JOOX_TIMEOUT)
location = res.header("location")
if 300 <= res.status_code < 400 and location:
current = urljoin(current, location)
continue
body = res.body if isinstance(res.body, str) else ""
return {"statusCode": res.status_code, "body": body, "finalUrl": current}
raise RuntimeError("joox 页面重定向次数过多")
+65
View File
@@ -0,0 +1,65 @@
"""酷狗音乐(对应 sdk/kg/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析,
顶层挂 get_music_url(取流在 apis/kg.py)、get_lyric、get_pic、get_music_info
(hash 反查歌名/歌手/时长,供取流失败的酷我兜底使用)、get_music_detail_page_url。
"""
from __future__ import annotations
from typing import Any
from app.sdk.apis.kg import get_music_url
from app.sdk.platforms._base import Platform
from app.sdk.platforms.kg import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_info,
music_search,
parse,
pic,
playlist,
singer,
song_list,
)
from app.sdk.platforms.kg.util import get_device
async def _init() -> None:
"""预先固化设备指纹(取流签名依赖 dfid/mid,首次调用时生成并持久化)。"""
get_device()
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
hash_value = song_info.get("hash") or song_info.get("songmid")
album_id = song_info.get("albumId")
return f"https://www.kugou.com/song/#hash={hash_value}&album_id={album_id}"
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""路由兜底调用:传 `{"hash": id, "songmid": id}`,返回含 name/singer/interval 的 dict。"""
return await music_info.get_music_info_for_song_info(song_info)
MODULE = Platform(
id="kg",
name="酷狗音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
singer=singer,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
init=_init,
)
+150
View File
@@ -0,0 +1,150 @@
"""酷狗专辑(对应 sdk/kg/album.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch, encode_uri_component
ALBUM_INFO_URL = (
"http://kmrserviceretry.kugou.com/container/v1/album"
"?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589"
)
ALBUM_SONG_URL = "http://mobiles.kugou.com/api/v3/album/song"
ALBUM_SEARCH_URL = "http://mobilecdn.kugou.com/api/v3/search/album"
_UA_MOBILE = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _album_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
async def _get_album_info(id: Any) -> dict[str, Any]:
result = await create_http_fetch(
ALBUM_INFO_URL,
{
"method": "POST",
"body": {
"appid": 1005,
"clienttime": 1681833686,
"clientver": 11589,
"data": [{"album_id": id}],
"fields": (
"language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,"
"quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,"
"exclusive,is_publish,trans_param,authors,album_tag"
),
"isBuy": 0,
"key": "e6f3306ff7e2afb494e89fbbda0becbf",
"mid": "22945702112173152889429073101964063697",
"show_album_tag": 0,
},
},
)
if not result:
raise RuntimeError("get album info failed.")
info = result[0] if isinstance(result, list) else result
return {
"name": info.get("album_name"),
"image": str(info.get("sizable_cover") or "").replace("{size}", "240"),
"desc": info.get("intro"),
"authorName": info.get("author_name"),
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 200) -> dict[str, Any]:
album_list = await create_http_fetch(
f"{ALBUM_SONG_URL}?version=9108&albumid={album_id}&plat=0&pagesize={limit}"
f"&area_code=0&page={page}&with_res_tag=0"
)
album_list = album_list or {}
if not album_list.get("info"):
raise RuntimeError("Get album list failed.")
songs = await get_music_infos_by_list(album_list.get("info") or [])
info = await _get_album_info(album_id)
return {
"list": songs or [],
"page": page,
"limit": limit,
"total": album_list.get("total"),
"source": "kg",
"info": {
"name": info["name"],
"img": info["image"],
"desc": info["desc"],
"author": info["authorName"],
},
}
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 10))
url = f"{ALBUM_SEARCH_URL}?keyword={encode_uri_component(word)}&format=json&page={page_no}&pagesize={page_size}"
resp = await http_fetch(url, headers={"User-Agent": _UA_MOBILE, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗专辑搜索失败"))
data = body.get("data") or {}
songs: list[dict[str, Any]] = []
for item in data.get("info") or []:
album_id = str(item.get("albumid") or "").strip()
if not album_id or album_id == "0":
continue
songs.append(
{
"id": album_id,
"name": str(item.get("albumname") or "").strip(),
"cover": _album_cover(item.get("imgurl")),
"trackCount": int(item.get("songcount") or 0) or 0,
"creator": str(item.get("singername") or "").strip(),
"description": str(item.get("intro") or "").strip(),
"publishTime": str(item.get("publishtime") or "").strip(),
"link": f"https://www.kugou.com/album/{album_id}.html",
"source": "kg",
"type": "album",
}
)
return {
"source": "kg",
"list": songs,
"page": page_no,
"limit": page_size,
"total": int(data.get("total") or 0) or len(songs),
}
async def get_album_songs(id: Any) -> list[dict[str, Any]]:
album_id = str(id or "").strip()
if not album_id:
raise RuntimeError("酷狗专辑 id 为空")
result = await get_album_detail(album_id, 1, 10000)
return result["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""album/single/<id>.html、yy/album/single/<id>.html、album/<id>.html、albumid=<id>。"""
import re
raw = str(link or "")
for pattern in (
r"album/single/(\d+)\.html",
r"yy/album/single/(\d+)\.html",
r"album/(\d+)\.html",
r"albumid=(\d+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1), 1)
raise RuntimeError("无效的酷狗专辑链接")
+128
View File
@@ -0,0 +1,128 @@
"""酷狗评论(对应 sdk/kg/comment.ts)。
签名:`signatureParams(params)` —— 参数串按键排序拼接后以 android 盐包裹取 MD5。
返回形状是 `{source, comments, total, page, limit, maxPage}`(不是 `list`),
`filterComment` 还要处理 `atlist` 替换与 `pcontent`(楼中楼)两种形态。
"""
from __future__ import annotations
import math
import time
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2, decode_name
from app.sdk.platforms.kg.util import signature_params
_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24"
)
def _comment_params(hash_value: str, page: int, limit: int) -> str:
return (
"dfid=0&mid=16249512204336365674023395779019"
f"&clienttime={int(time.time() * 1000)}&uuid=0&extdata={hash_value}"
"&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&schash={hash_value}&clientver=11409&p={page}&clienttoken=&pagesize={limit}"
"&ver=10&kugouid=0"
)
def _replace_at(raw: str, at_list: list[dict[str, Any]]) -> str:
for item in at_list:
raw = raw.replace(f"[at={item.get('id')}]", f"@{item.get('name')} ")
return raw
def _format_comment(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
content = item.get("content") or ""
if item.get("atlist"):
content = _replace_at(str(content), item["atlist"])
images = [image.get("url") for image in item.get("images") or []] if item.get("images") else []
like = item.get("like") or {}
data = {
"id": item.get("id"),
"text": decode_name(content),
"images": images,
"location": item.get("location"),
"time": item.get("addtime"),
"timeStr": date_format2(item.get("addtime") or 0),
"userName": item.get("user_name"),
"avatar": item.get("user_pic"),
"userId": item.get("user_id"),
"likedCount": like.get("likenum"),
"replyNum": item.get("reply_num"),
"reply": [],
}
if item.get("pcontent"):
# 楼中楼:父评论包一层,原评论挂在 reply 里
result.append(
{
"id": item.get("id"),
"text": decode_name(item["pcontent"]),
"time": None,
"userName": item.get("puser"),
"avatar": None,
"userId": item.get("puser_id"),
"likedCount": None,
"replyNum": None,
"reply": [data],
}
)
else:
result.append(data)
return result
async def _get_comment(hash_value: str, page: int, limit: int, hot: bool) -> dict[str, Any]:
path = "topliked" if hot else "newest"
params = _comment_params(hash_value, page, limit)
resp = await http_fetch(
f"http://m.comment.service.kugou.com/r/v1/rank/{path}?{params}&signature={signature_params(params)}",
headers={"User-Agent": _UA},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
total = body.get("count") or 0
return {
"source": "kg",
"comments": _format_comment(body.get("list") or []),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, False)
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, True)
async def get_reply_comment(
song_info: dict[str, Any], reply_id: Any, page: int = 1, limit: int = 100
) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "")
children_id = str(song_info.get("audioId") or "").split("_")[0] if len(songmid) == 32 else songmid
resp = await http_fetch(
"http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike"
"&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&p={page}&pagesize={limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1"
f"&appid=1001&childrenid={children_id}&tid={reply_id}",
headers={"User-Agent": "Android712-AndroidPhone-8983-18-0-COMMENT-wifi"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取回复评论失败")
return {"source": "kg", "comments": _format_comment(body.get("list") or [])}
+41
View File
@@ -0,0 +1,41 @@
"""酷狗热搜(对应 sdk/kg/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
HOT_API = (
"http://gateway.kugou.com/api/v3/search/hot_tab"
"?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0"
)
HEADERS = {
"dfid": "1ssiv93oVqMp27cirf2CvoF1",
"mid": "156798703528610303473757548878786007104",
"clienttime": "1584257267",
"x-router": "msearch.kugou.com",
"user-agent": "Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi",
"kg-rc": "1",
}
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
result: list[str] = []
for item in raw_list:
for keyword in item.get("keywords") or []:
result.append(decode_name(keyword.get("keyword")))
return result
async def get_list(retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_API, method="get", headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("errcode") != 0:
raise RuntimeError("获取热搜词失败")
data = body.get("data") or {}
return {"source": "kg", "list": _filter_list(data.get("list") or [])}
+102
View File
@@ -0,0 +1,102 @@
"""酷狗 KRC 歌词解密(对应 sdk/kg/krcDecode.ts)。
密文是 base64 → 去掉 4 字节头 → 与 16 字节固定 key 循环异或 → zlib inflate。
解密后是带 `[start,duration]` 行级标签与 `<offset,duration,0>` 字级标签的 KRC 文本,
另外 `[language:<base64>]` 行里藏着 base64(JSON) 的逐行翻译,需按行号拼回 LRC。
"""
from __future__ import annotations
import base64
import json
import re
import zlib
from typing import Any
from app.sdk.common.utils import decode_name
ENC_KEY = bytes([0x40, 0x47, 0x61, 0x77, 0x5E, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2D, 0xCE, 0xD2, 0x6E, 0x69])
_HEAD_EXP = re.compile(r"^.*\[id:\$\w+\]\n")
_TRANS_EXP = re.compile(r"\[language:([\w=\\/+]+)\]")
_TRANS_LINE_EXP = re.compile(r"\[language:[\w=\\/+]+\]\n")
_LINE_EXP = re.compile(r"\[((\d+),\d+)\].*")
_WORD_TAG_EXP = re.compile(r"<(\d+,\d+),\d+>")
_PLAIN_TAG_EXP = re.compile(r"<\d+,\d+>")
def _xor_decode(data: str) -> str:
if not data:
return ""
buf = bytearray(base64.b64decode(data)[4:])
for i in range(len(buf)):
buf[i] ^= ENC_KEY[i % 16]
return zlib.decompress(bytes(buf)).decode("utf-8", errors="replace")
def _format_time(ms: int) -> str:
"""毫秒 → `mm:ss.ms`(毫秒段与原文一致不补零)。"""
minutes, rem = divmod(ms, 60000)
return f"{minutes:02d}:{rem // 1000:02d}.{ms % 1000}"
def _lrc_line(value: Any) -> str:
"""rlyric/tlyric 的单项是字符串数组(逐字),字符串形态也容忍。"""
if isinstance(value, list):
return "".join(str(item) for item in value)
return "" if value is None else str(value)
def parse_lyric(text: str) -> dict[str, str]:
text = text.replace("\r", "")
if _HEAD_EXP.match(text):
text = _HEAD_EXP.sub("", text, count=1)
rlyric: list[Any] | None = None
tlyric: list[Any] | None = None
trans = _TRANS_EXP.search(text)
if trans:
text = _TRANS_LINE_EXP.sub("", text, count=1)
try:
payload = json.loads(base64.b64decode(trans.group(1)).decode("utf-8", errors="replace"))
except ValueError:
payload = {}
for item in payload.get("content") or []:
if item.get("type") == 0:
rlyric = item.get("lyricContent")
elif item.get("type") == 1:
tlyric = item.get("lyricContent")
index = 0
def repl(match: re.Match[str]) -> str:
nonlocal index
line_match = _LINE_EXP.match(match.group(0))
time_ms = int(line_match.group(2)) if line_match else 0
stamp = _format_time(time_ms)
if rlyric is not None:
rlyric[index] = f"[{stamp}]{_lrc_line(rlyric[index]) if index < len(rlyric) else ''}"
if tlyric is not None:
tlyric[index] = f"[{stamp}]{_lrc_line(tlyric[index]) if index < len(tlyric) else ''}"
index += 1
return match.group(0).replace(match.group(1), stamp, 1)
yrc = re.sub(r"\[((\d+),\d+)\].*", repl, text)
rlyric_text = "\n".join(str(item) for item in rlyric) if rlyric else ""
tlyric_text = "\n".join(str(item) for item in tlyric) if tlyric else ""
yrc = _WORD_TAG_EXP.sub(r"<\1>", yrc)
yrc = decode_name(yrc)
lyric = _PLAIN_TAG_EXP.sub("", yrc)
return {
"lyric": lyric,
"tlyric": decode_name(tlyric_text),
"rlyric": decode_name(rlyric_text),
"yrc": yrc,
}
async def decode_krc(data: str) -> dict[str, str]:
return parse_lyric(_xor_decode(data))
__all__ = ["decode_krc", "parse_lyric"]
+110
View File
@@ -0,0 +1,110 @@
"""酷狗榜单(对应 sdk/kg/leaderboard.ts)。
榜单 id 表是硬编码的静态表(原实现 getBoards 直接返回它,不请求上游);
详情走 `mobilecdnbj.kugou.com/api/v3/rank/song`,返回的 hash 需再反查一次 SongInfo
才能拿到多档音质。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, size_formate
LIST_DETAIL_LIMIT = 100
BOARD_LIST: list[dict[str, str]] = [
{"id": "kgtop500", "name": "TOP500", "bangid": "8888"},
{"id": "kgwlhgb", "name": "网络榜", "bangid": "23784"},
{"id": "kgbsb", "name": "飙升榜", "bangid": "6666"},
{"id": "kgfxb", "name": "分享榜", "bangid": "21101"},
{"id": "kgcyyb", "name": "纯音乐榜", "bangid": "33164"},
{"id": "kggfjqb", "name": "古风榜", "bangid": "33161"},
{"id": "kgyyjqb", "name": "粤语榜", "bangid": "33165"},
{"id": "kgomjqb", "name": "欧美榜", "bangid": "33166"},
{"id": "kgdyrgb", "name": "电音榜", "bangid": "33160"},
{"id": "kgjdrgb", "name": "DJ热歌榜", "bangid": "24971"},
{"id": "kghyxgb", "name": "华语新歌榜", "bangid": "31308"},
]
RANK_API = "http://mobilecdnbj.kugou.com/api/v3/rank/song"
def get_url(page: int, bangid: str, limit: int = LIST_DETAIL_LIMIT) -> str:
return (
f"{RANK_API}?version=9108&ranktype=1&plat=0&pagesize={limit}"
f"&area_code=1&page={page}&rankid={bangid}&with_res_tag=0&show_portrait_mv=1"
)
def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "320filesize", "320hash"),
("flac", "sqfilesize", "sqhash"),
("flac24bit", "filesize_high", "hash_high"),
):
if item.get(size_key, 0) == 0:
continue
size = size_formate(item.get(size_key))
types.append({"type": quality, "size": size, "hash": item.get(hash_key)})
_types[quality] = {"size": size, "hash": item.get(hash_key)}
result.append(
{
"singer": _singer(item.get("authors")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(item.get("remark")),
"albumId": item.get("album_id"),
"songmid": item.get("audio_id"),
"source": "kg",
"interval": format_play_time(item.get("duration")),
"img": None,
"lrc": None,
"hash": item.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _singer(authors: list[dict[str, Any]] | None) -> str:
names = [str(author["author_name"]) for author in authors or [] if author.get("author_name")]
return decode_name("、".join(names))
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "kg"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
resp: Any
try:
resp = await http_fetch(get_url(page, bangid))
except Exception: # noqa: BLE001 - 可降级失败:换一次请求重试
return await get_list(bangid, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
return await get_list(bangid, page, retry_num + 1)
data = body.get("data") or {}
return {
"total": data.get("total"),
"list": _filter_data(data.get("info") or []),
"limit": 100,
"page": page,
"source": "kg",
}
def get_detail_page_url(id: Any) -> str:
value = str(id).replace("kg__", "") if isinstance(id, str) else id
return f"https://www.kugou.com/yy/rank/home/1-{value}.html"
+91
View File
@@ -0,0 +1,91 @@
"""酷狗歌词(对应 sdk/kg/lyric.ts)。
两步:`lyrics.kugou.com/search` 按歌名+hash+时长找候选 → `lyrics.kugou.com/download`
取回 base64 内容。`fmt=krc` 需走 krc_decode 解密,`fmt=lrc` 直接 base64 解码。
"""
from __future__ import annotations
import base64
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.krc_decode import decode_krc
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def get_intv(interval: Any) -> int:
"""`mm:ss` → 毫秒(原文把秒当毫秒拼给上游,这里保持一致)。"""
if not interval:
return 0
parts = str(interval).split(":")
total = 0
unit = 1
while parts:
part = parts.pop()
try:
total += int(float(part)) * unit
except ValueError:
pass
unit *= 60
return int(total)
async def _search_lyric(name: str, hash_value: str, time: Any, try_num: int = 0) -> dict[str, str] | None:
from app.sdk.platforms.kg.util import encode_uri_component
url = (
"http://lyrics.kugou.com/search?ver=1&man=yes&client=pc"
f"&keyword={encode_uri_component(name)}&hash={hash_value}&timelength={time}&lrctxt=1"
)
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
return await _search_lyric(name, hash_value, time, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
candidates = body.get("candidates") or []
if not candidates:
return None
info = candidates[0]
fmt = "krc" if info.get("krctype") == 1 and info.get("contenttype") != 1 else "lrc"
return {"id": str(info.get("id")), "accessKey": str(info.get("accesskey")), "fmt": fmt}
async def _get_lyric_download(id: str, access_key: str, fmt: str, try_num: int = 0) -> dict[str, str]:
url = f"http://lyrics.kugou.com/download?ver=1&client=pc&id={id}&accesskey={access_key}&fmt={fmt}&charset=utf8"
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
# 重试必须回到本方法(搜索接口与下载接口的入参语义不同)
return await _get_lyric_download(id, access_key, fmt, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
content = str(body.get("content") or "")
match body.get("fmt"):
case "krc":
return await decode_krc(content)
case "lrc":
return {
"lyric": base64.b64decode(content).decode("utf-8", errors="replace"),
"tlyric": "",
"rlyric": "",
"yrc": "",
}
case _:
raise RuntimeError(f"未知歌词格式: {body.get('fmt')}")
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
name = str(song_info.get("name") or "")
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
time = song_info.get("_interval") or get_intv(song_info.get("interval"))
result = await _search_lyric(name, hash_value, time)
if not result:
raise RuntimeError("Get lyric failed")
return await _get_lyric_download(result["id"], result["accessKey"], result["fmt"])
+138
View File
@@ -0,0 +1,138 @@
"""按 hash 批量反查歌曲信息(对应 sdk/kg/musicInfo.ts)。
多个模块(榜单/歌单/专辑/歌手/解析/路由兜底)都要把 hash 换成完整 SongInfo,
统一走这里:`http://gateway.kugou.com/v3/album_audio/audio`,每批最多 100 个 hash。
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.sdk.common.utils import decode_name, format_play_time, size_formate
from app.sdk.platforms.kg.util import create_http_fetch
MUSIC_INFO_URL = "http://gateway.kugou.com/v3/album_audio/audio"
MUSIC_INFO_HEADERS = {
"KG-THash": "13a3164",
"KG-RC": "1",
"KG-Fake": "0",
"KG-RF": "00869891",
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
"x-router": "kmr.service.kugou.com",
}
_FIELDS = "album_info,author_name,audio_info,ori_audio_name,base,songname,classification"
def _filter_music_info_list(raw_list: list[Any]) -> list[dict[str, Any]]:
ids: set[Any] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
if not item:
continue
audio = item.get("audio_info") or {}
if audio.get("audio_id") in ids:
continue
ids.add(audio.get("audio_id"))
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "filesize_320", "hash_320"),
("flac", "filesize_flac", "hash_flac"),
("flac24bit", "filesize_high", "hash_high"),
):
if str(audio.get(size_key, "0")) == "0":
continue
size = size_formate(int(audio.get(size_key) or 0))
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
album = item.get("album_info") or {}
result.append(
{
"singer": decode_name(item.get("author_name")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(album.get("album_name")),
"albumId": album.get("album_id"),
"songmid": audio.get("audio_id"),
"source": "kg",
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
"img": None,
"lrc": None,
"hash": audio.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _build_tasks(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
base = {
"area_code": "1",
"show_privilege": 1,
"show_album_info": "1",
"is_publish": "",
"appid": 1005,
"clientver": 11451,
"mid": "1",
"dfid": "-",
"clienttime": int(time.time() * 1000),
"key": "OIlwieks28dk2k092lksi2UIkp",
"fields": _FIELDS,
}
tasks: list[dict[str, Any]] = []
rest = list(hashs)
while rest:
tasks.append({"data": rest[:100], **base})
if len(rest) < 100:
break
rest = rest[100:]
return tasks
async def get_music_infos(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 hash 列表批量取 SongInfo(上游按 100 个一批并发,扁平化后去重)。"""
async def one(task: dict[str, Any]) -> list[Any]:
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": task, "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
return [entry[0] if entry else None for entry in items]
chunks = await asyncio.gather(*(one(task) for task in _build_tasks(hashs)))
return _filter_music_info_list([item for chunk in chunks for item in chunk])
async def get_music_info_raw(hash: str) -> Any:
tasks = _build_tasks([{"hash": hash}])
if not tasks:
return None
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": tasks[0], "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
flat = [entry[0] if entry else None for entry in items]
return flat[0] if flat else None
async def get_music_info(hash: str) -> dict[str, Any] | None:
infos = await get_music_infos([{"hash": hash}])
return infos[0] if infos else None
async def get_music_infos_by_list(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""上游返回的 {hash} 列表 → 完整 SongInfo 列表。"""
if not items:
return []
return await get_music_infos([{"hash": item.get("hash")} for item in items])
async def get_music_info_for_song_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""顶层 `get_music_info(song_info)` 的实现:hash 优先,退回 songmid。"""
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "").strip()
if not hash_value:
return None
return await get_music_info(hash_value)
+97
View File
@@ -0,0 +1,97 @@
"""酷狗搜索(对应 sdk/kg/musicSearch.ts)。"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, format_singer_name, size_formate
from app.sdk.platforms.kg.util import encode_uri_component
DEFAULT_LIMIT = 30
_SEARCH_API = "https://songsearch.kugou.com/song_search_v2"
def _filter_data(raw: dict[str, Any]) -> dict[str, Any]:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "FileSize", "FileHash"),
("320k", "HQFileSize", "HQFileHash"),
("flac", "SQFileSize", "SQFileHash"),
("flac24bit", "ResFileSize", "ResFileHash"),
):
if raw.get(size_key, 0) == 0:
continue
size = size_formate(raw.get(size_key))
types.append({"type": quality, "size": size, "hash": raw.get(hash_key)})
_types[quality] = {"size": size, "hash": raw.get(hash_key)}
return {
"singer": decode_name(format_singer_name(raw.get("Singers"), "name")),
"name": decode_name(raw.get("SongName")),
"albumName": decode_name(raw.get("AlbumName")),
"albumId": raw.get("AlbumID"),
"songmid": raw.get("Audioid"),
"source": "kg",
"interval": format_play_time(raw.get("Duration")),
"_interval": raw.get("Duration"),
"img": None,
"lrc": None,
"otherSource": None,
"hash": raw.get("FileHash"),
"types": types,
"_types": _types,
"typeUrl": {},
}
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同名同 hash 去重;`Grp` 是同一首歌的其它版本,也一并展开。"""
ids: set[str] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
key = f"{item.get('Audioid')}{item.get('FileHash')}"
if key in ids:
continue
ids.add(key)
result.append(_filter_data(item))
for child in item.get("Grp") or []:
child_key = f"{child.get('Audioid')}{child.get('FileHash')}"
if child_key in ids:
continue
ids.add(child_key)
result.append(_filter_data(child))
return result
async def search(keyword: str, page: int = 1, limit: int = DEFAULT_LIMIT, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = (
f"{_SEARCH_API}?keyword={encode_uri_component(keyword)}&page={page}&pagesize={page_size}"
"&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1"
)
try:
resp = await http_fetch(url)
except Exception: # noqa: BLE001 - 可降级失败:原实现同样重试同类请求
return await search(keyword, page, limit, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("error_code") != 0:
return await search(keyword, page, limit, retry_num + 1)
data = body.get("data") or {}
raw_list = data.get("lists")
if raw_list is None:
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(raw_list)
total = data.get("total") or 0
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"limit": page_size,
"total": total,
"source": "kg",
}
+133
View File
@@ -0,0 +1,133 @@
"""酷狗链接解析(对应 sdk/kg/parse.ts)。
歌曲:`https://www.kugou.com/song/#hash=<32位hash>`(含 &album_id= 等尾串)或裸 hash。
元数据取 `m.kugou.com/app/i/getSongInfo.php`(songName/author_name/timeLength/album_img/bitRate),
再并发合并音乐信息里的专辑与多档 hash;直链优先用 playInfo 的 url,仍为空时用 apis/kg
签名通道尽力补一次 —— 失败只返回元数据(与上游付费曲语义一致)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kg import album, playlist
from app.sdk.platforms.kg.music_info import get_music_info
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
HASH_RE = re.compile(r"hash=([a-fA-F0-9]{32})", re.IGNORECASE)
RAW_HASH_RE = re.compile(r"^[a-fA-F0-9]{32}$")
def extract_hash(link: Any) -> str:
"""链接/裸 hash → 32 位小写 hash;都不匹配返回空串。"""
raw = str(link if link is not None else "").strip()
match = HASH_RE.search(raw)
if match:
return match.group(1).lower()
return raw.lower() if RAW_HASH_RE.match(raw) else ""
async def _fetch_song_info_php(hash_value: str) -> dict[str, Any] | None:
resp = await http_fetch(
f"http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash_value}",
method="get",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return None
body = resp.body
if int(body.get("errcode") or 0) != 0 or not body.get("hash"):
return None
return body
async def parse_song(link: str) -> dict[str, Any]:
import asyncio
hash_value = extract_hash(link)
if not hash_value:
raise RuntimeError("无效的酷狗歌曲链接(未找到 32 位 hash)")
raw, info = await asyncio.gather(
_fetch_song_info_php(hash_value), get_music_info(hash_value), return_exceptions=True
)
raw = None if isinstance(raw, BaseException) else raw
info = None if isinstance(info, BaseException) else info
if not raw and not info:
raise RuntimeError("酷狗歌曲不存在或已下架")
raw = raw or {}
info = info or {}
file_name = str(raw.get("fileName") or "")
fallback_name = " - ".join(file_name.split(" - ")[1:]) if " - " in file_name else ""
name = decode_name(str(raw.get("songName") or "").strip() or fallback_name or info.get("name") or "")
singer = decode_name(str(raw.get("author_name") or "").strip() or info.get("singer") or "")
duration = int(raw.get("timeLength") or 0) or 0
song: dict[str, Any] = {
"songmid": str(info.get("songmid") or raw.get("audio_id") or "").strip(),
"hash": str(raw.get("hash") or hash_value).lower(),
"name": name,
"singer": singer,
"albumName": decode_name(str(info.get("albumName") or "").strip()),
"albumId": str(info.get("albumId") or raw.get("albumid") or raw.get("req_albumid") or "").strip(),
"interval": format_play_time(duration) if duration > 0 else (info.get("interval") or "--/--"),
"img": str(raw.get("album_img") or "").replace("{size}", "240") or info.get("img") or None,
"lrc": None,
"otherSource": None,
"types": info.get("types") or [],
"_types": info.get("_types") or {},
"typeUrl": {},
"source": "kg",
"extra": {
"hash": str(raw.get("hash") or hash_value).lower(),
"url": str(raw.get("url") or ""),
"backup_url": raw.get("backup_url") or [],
"bitrate": int(raw.get("bitRate") or 0) or 0,
"link": f"https://www.kugou.com/song/#hash={hash_value}",
},
}
# playInfo 的 url/backup_url 是官方直链(付费曲为空),优先使用
if not song.get("url"):
backup = raw.get("backup_url") or []
raw_url = str(raw.get("url") or "") or str(backup[0] if backup else "")
if raw_url:
song["url"] = raw_url
song["type"] = "320k" if (int(raw.get("bitRate") or 0) or 0) >= 320000 else "128k"
# 仍无直链时(风控/付费)用签名通道尽力补一次
if not song.get("url"):
try:
from app.sdk.apis.kg import get_music_url
hit = await get_music_url(
{
"hash": song["hash"],
"albumId": song["albumId"],
"songmid": song["songmid"],
"_types": song["_types"],
},
"128k",
)
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游付费曲无直链的失败语义一致:元数据仍返回
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)
+62
View File
@@ -0,0 +1,62 @@
"""酷狗封面(对应 sdk/kg/pic.ts)。
走 `media.store.kugou.com/v1/get_res_privilege`,从返回的 `info.image` 里
把 `{size}` 占位替换为上游给的 `imgsize[0]`。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
PIC_API = "http://media.store.kugou.com/v1/get_res_privilege"
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def _album_audio_id(song_info: dict[str, Any]) -> Any:
"""songmid 为 32 位时是旧形态(audioId_x),要取 `_` 前的段(修复历史存储变更)。"""
songmid = str(song_info.get("songmid") or "")
if len(songmid) == 32:
return str(song_info.get("audioId") or "").split("_")[0]
return song_info.get("songmid")
async def get_pic(song_info: dict[str, Any]) -> str:
body = {
"appid": 1001,
"area_code": "1",
"behavior": "play",
"clientver": "9020",
"need_hash_offset": 1,
"relate": 1,
"resource": [
{
"album_audio_id": _album_audio_id(song_info),
"album_id": song_info.get("albumId"),
"hash": song_info.get("hash"),
"id": 0,
"name": f"{song_info.get('singer')} - {song_info.get('name')}.mp3",
"type": "audio",
}
],
"token": "",
"userid": 2626431536,
"vip": 1,
}
resp = await http_fetch(PIC_API, method="POST", headers=HEADERS, body=body)
payload = resp.body if isinstance(resp.body, dict) else {}
if payload.get("error_code") != 0:
raise RuntimeError("图片获取失败")
data = payload.get("data") or [{}]
info = (data[0] or {}).get("info") or {}
image = str(info.get("image") or "")
img = image.replace("{size}", str(info["imgsize"][0])) if info.get("imgsize") else image
if not img:
raise RuntimeError("Pic get failed")
return img
+233
View File
@@ -0,0 +1,233 @@
"""酷狗歌单模块(对应 sdk/kg/playlist.ts)—— 统一形状层。
解析链复用 song_list 的能力(special/single、songlist/gcid、share chain、zlist、
global_collection_id 等),本模块只做形状归一:摘要 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg import song_list
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _playlist_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
def _to_summary(item: dict[str, Any]) -> dict[str, Any] | None:
raw_id = _first_non_empty(item.get("specialid"), item.get("global_specialid"), item.get("id"))
playlist_id = re.sub(r"^id_", "", raw_id)
if not playlist_id:
return None
play_count = _finite(item.get("playcount"))
return {
"id": playlist_id,
"name": _first_non_empty(item.get("specialname"), item.get("name")),
"cover": _playlist_cover(_first_non_empty(item.get("imgurl"), item.get("img"))),
"trackCount": int(item.get("songcount") or item.get("total") or 0) or 0,
"playCount": play_count if play_count is not None else (item.get("play_count") or 0),
"creator": _first_non_empty(item.get("nickname"), item.get("username"), item.get("author")),
"description": _first_non_empty(item.get("intro"), item.get("desc")),
"publishTime": str(item.get("publishtime") or item.get("time") or "").strip(),
"link": f"https://www.kugou.com/yy/special/single/{playlist_id}.html",
"source": "kg",
}
def _finite(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number and number not in (float("inf"), float("-inf")) else None
def _normalize_playlist_id(id: Any) -> str:
"""数字 id / id_ 前缀 / 链接 → song_list.get_list_detail 可识别的入参。"""
raw = str(id or "").strip()
if not raw:
return ""
return f"id_{raw}" if re.fullmatch(r"\d+", raw) else raw
def _parse_category_id(category_id: Any) -> tuple[str, str]:
parts = str(category_id or "").strip().split(":")
return str(parts[0] if parts else "").strip(), (str(parts[1]).strip() if len(parts) > 1 else "") or "0"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
res = await song_list.search(word, page, limit)
items = [summary for summary in (_to_summary(item) for item in res.get("list") or []) if summary]
return {
"source": "kg",
"list": items,
"page": page,
"limit": limit,
"total": int(res.get("total") or 0) or len(items),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
request_id = _normalize_playlist_id(playlist_id)
if not request_id:
raise RuntimeError("酷狗歌单 id 为空")
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
res = await song_list.get_list_detail(request_id, page_no)
if not res:
raise RuntimeError("酷狗歌单不存在或解析失败")
songs: list[dict[str, Any]] = res.get("list") or []
total = int(res.get("total") or 0) or len(songs)
if len(songs) > page_size:
start = (page_no - 1) * page_size
songs = songs[start : start + page_size]
info = res.get("info") or {}
return {
"source": "kg",
"info": {
"name": _first_non_empty(info.get("name"), str(playlist_id)),
"img": _playlist_cover(info.get("img")),
"desc": _first_non_empty(info.get("desc")),
"author": _first_non_empty(info.get("author")),
"play_count": info.get("play_count") or "",
},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
res = await song_list.get_list_detail(_normalize_playlist_id(playlist_id), 1)
return (res or {}).get("list") or []
async def get_categories() -> dict[str, Any]:
resp = await http_fetch(
"http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗歌单分类获取失败"))
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
for group in (body.get("data") or {}).get("info") or []:
group_name = str(group.get("name") or "").strip()
group_id = int(group.get("id") or 0) or 0
if group_id > 0 and group_name:
items.append({"id": f"{group_id}:0", "name": group_name, "group": group_name})
for child in group.get("children") or []:
child_id = int(child.get("id") or 0) or 0
child_name = str(child.get("name") or "").strip()
if not child_id or not child_name:
continue
tag_id = int(child.get("special_tag_id") or 0) or 0
items.append(
{
"id": f"{child_id}:{tag_id}",
"name": child_name,
"group": group_name,
"hot": int(child.get("is_hot") or 0) == 1,
}
)
return {"source": "kg", "list": items}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
category, tag_id = _parse_category_id(category_id)
if not category:
listing = await get_categories()
first = next((item for item in listing["list"] if item.get("id")), None)
if not first:
raise RuntimeError("酷狗歌单分类为空")
category, tag_id = _parse_category_id(first["id"])
api_url = "http://mobilecdnbj.kugou.com/api/v3/tag/specialList?" + "&".join(
["plat=0", f"page={page_no}", f"tagid={tag_id}", f"pagesize={page_size}", "ugc=1", f"id={category}", "sort=2"]
)
resp = await http_fetch(api_url, headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗分类歌单获取失败"))
items: list[dict[str, Any]] = []
for entry in (body.get("data") or {}).get("info") or []:
summary = _to_summary(entry) or _to_summary({**entry, "specialid": entry.get("global_specialid")})
if not summary:
continue
summary["categoryId"] = f"{category}:{tag_id}"
items.append(summary)
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int((body.get("data") or {}).get("total") or 0) or len(items),
}
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 30))
resp = await http_fetch(
f"http://m.kugou.com/plist/index&json=true&page={page_no}",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError("酷狗推荐歌单获取失败")
plist = (body.get("plist") or {}).get("list") or {}
items = [summary for summary in (_to_summary(item) for item in plist.get("info") or []) if summary]
if len(items) > page_size:
items = items[:page_size]
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int(plist.get("total") or 0) or len(items),
}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""special/single/<id>.html 与 songlist/<gcid_xxx>/ 优先,其余交给 song_list 通用解析。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的酷狗歌单链接")
special = re.search(r"special/single/(\d+)\.html", raw)
if special:
return await get_playlist_detail(special.group(1), 1)
gcid = re.search(r"songlist/(gcid_[A-Za-z0-9]+)", raw)
if gcid:
return await get_playlist_detail(gcid.group(1), 1)
return await get_playlist_detail(raw, 1)
+85
View File
@@ -0,0 +1,85 @@
"""酷狗歌手(对应 sdk/kg/singer.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch
SINGER_INFO_URL = "http://mobiles.kugou.com/api/v5/singer/info"
SINGER_ALBUM_URL = "http://mobiles.kugou.com/api/v5/singer/album"
SINGER_SONG_URL = "http://mobiles.kugou.com/api/v5/singer/song"
def _check_id(id: Any) -> None:
# kg 源有些歌曲在歌手未被酷狗收录时返回的歌手 id 为 0
if str(id) in ("0", ""):
raise RuntimeError("歌手不存在")
async def get_info(id: str) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_INFO_URL}?singerid={id}")
body = body or {}
if not body:
raise RuntimeError("get singer info faild.")
imgurl = str(body.get("imgurl") or "")
return {
"source": "kg",
"id": body.get("singerid"),
"info": {
"name": body.get("singername"),
"desc": body.get("intro"),
"avatar": imgurl.replace("{size}", "480") if imgurl else "",
"gender": "man" if body.get("grade") == 1 else "woman",
},
"count": {"music": body.get("songcount"), "album": body.get("albumcount")},
}
def _filter_album_list(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"id": item.get("albumid"),
"count": item.get("songcount"),
"info": {
"name": item.get("albumname"),
"author": item.get("singername"),
"img": str(item.get("imgurl") or item.get("img") or "").replace("{size}", "480"),
"desc": item.get("intro"),
},
}
for item in raw
]
async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_ALBUM_URL}?singerid={id}&page={page}&pagesize={limit}")
body = body or {}
if not body.get("info"):
raise RuntimeError("get singer album list faild.")
return {
"source": "kg",
"list": _filter_album_list(body.get("info") or []),
"limit": limit,
"page": page,
"total": body.get("total"),
}
async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_SONG_URL}?singerid={id}&page={page}&pagesize={limit}")
body = body or {}
if not body.get("info"):
raise RuntimeError("get singer song list faild.")
songs = await get_music_infos_by_list(body.get("info") or [])
return {
"source": "kg",
"list": songs,
"limit": limit,
"page": page,
"total": body.get("total"),
}
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more