diff --git a/.gitignore b/.gitignore index 88217ab..c7cdcdf 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,10 @@ coverage/ # 以下规则仅作保险,防止旧的发射产物被误生成后提交。 src/**/*.js sdk/**/*.js + +# Python(uv) +.venv/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..fdcfcfd --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..07def20 --- /dev/null +++ b/app/__init__.py @@ -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" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5d06ce5 --- /dev/null +++ b/app/config.py @@ -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) diff --git a/app/http/__init__.py b/app/http/__init__.py new file mode 100644 index 0000000..e1dd3b3 --- /dev/null +++ b/app/http/__init__.py @@ -0,0 +1 @@ +"""服务端横切能力:错误、鉴权、限流、流签名、咪咕解密代理。""" diff --git a/app/http/api_error.py b/app/http/api_error.py new file mode 100644 index 0000000..18a9f6b --- /dev/null +++ b/app/http/api_error.py @@ -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) diff --git a/app/http/auth_gate.py b/app/http/auth_gate.py new file mode 100644 index 0000000..d3b6a88 --- /dev/null +++ b/app/http/auth_gate.py @@ -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) diff --git a/app/http/envelope.py b/app/http/envelope.py new file mode 100644 index 0000000..793e7a9 --- /dev/null +++ b/app/http/envelope.py @@ -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": }。""" + + 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) diff --git a/app/http/mg_stream.py b/app/http/mg_stream.py new file mode 100644 index 0000000..02abecc --- /dev/null +++ b/app/http/mg_stream.py @@ -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("重定向次数过多") diff --git a/app/http/rate_limit.py b/app/http/rate_limit.py new file mode 100644 index 0000000..8a8b2a3 --- /dev/null +++ b/app/http/rate_limit.py @@ -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 diff --git a/app/http/stream_sign.py b/app/http/stream_sign.py new file mode 100644 index 0000000..00c3909 --- /dev/null +++ b/app/http/stream_sign.py @@ -0,0 +1,38 @@ +"""流代理签名 —— /stream/* 的无头客户端兼容方案。 + +/url 返回流地址时附加 `?exp=<秒级时间戳>&sig=`。 +与裸 ?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) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..066ef22 --- /dev/null +++ b/app/main.py @@ -0,0 +1,199 @@ +"""FastAPI 应用装配 —— 中间件、异常处理、OpenAPI 文档、路由注册。 + +与 Node 版语义对齐: + * 成功响应统一包成 { code: 0, data }(由路由处理函数调用 `ok()` 显式构造, + 文档页、OpenAPI 规范与 /stream 媒体流为 raw 响应) + * 业务错误 { code: , 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() diff --git a/app/openapi/components.py b/app/openapi/components.py new file mode 100644 index 0000000..5f720fb --- /dev/null +++ b/app/openapi/components.py @@ -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, + }, +} diff --git a/app/openapi/ui.py b/app/openapi/ui.py new file mode 100644 index 0000000..5bdd319 --- /dev/null +++ b/app/openapi/ui.py @@ -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""" + + + + + {title} (ReDoc) + + + + + + +""" diff --git a/app/routes/login.py b/app/routes/login.py new file mode 100644 index 0000000..126aefd --- /dev/null +++ b/app/routes/login.py @@ -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"), + } + ) diff --git a/app/routes/music.py b/app/routes/music.py new file mode 100644 index 0000000..59bab9f --- /dev/null +++ b/app/routes/music.py @@ -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 为 :" + "(见 /album/qt),lr 为 :
(见 /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:::、" + "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}) diff --git a/app/routes/refresh.py b/app/routes/refresh.py new file mode 100644 index 0000000..f31b968 --- /dev/null +++ b/app/routes/refresh.py @@ -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 diff --git a/app/routes/system.py b/app/routes/system.py new file mode 100644 index 0000000..8056724 --- /dev/null +++ b/app/routes/system.py @@ -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}) diff --git a/app/sdk/__init__.py b/app/sdk/__init__.py new file mode 100644 index 0000000..a3576a4 --- /dev/null +++ b/app/sdk/__init__.py @@ -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 diff --git a/app/sdk/apis/__init__.py b/app/sdk/apis/__init__.py new file mode 100644 index 0000000..56367e6 --- /dev/null +++ b/app/sdk/apis/__init__.py @@ -0,0 +1,5 @@ +"""取流接口层(对应原 sdk/apis/*.ts)。 + +每个模块导出 `SUPPORT_QUALITYS: list[str]` 与 `async def get_music_url(song_info, quality, retry_num=0)`, +返回 `{"type": <实际命中音质>, "url": <播放地址>}`。降级链由各平台自行实现。 +""" diff --git a/app/sdk/apis/apple.py b/app/sdk/apis/apple.py new file mode 100644 index 0000000..cc7a17f --- /dev/null +++ b/app/sdk/apis/apple.py @@ -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) diff --git a/app/sdk/apis/bd.py b/app/sdk/apis/bd.py new file mode 100644 index 0000000..0540b82 --- /dev/null +++ b/app/sdk/apis/bd.py @@ -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) diff --git a/app/sdk/apis/bili.py b/app/sdk/apis/bili.py new file mode 100644 index 0000000..2362df4 --- /dev/null +++ b/app/sdk/apis/bili.py @@ -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 时回源 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 需为 | 格式") + 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]} diff --git a/app/sdk/apis/fivesing.py b/app/sdk/apis/fivesing.py new file mode 100644 index 0000000..cf652a6 --- /dev/null +++ b/app/sdk/apis/fivesing.py @@ -0,0 +1,43 @@ +"""5sing 取流(对应 sdk/apis/5sing.ts,上游 fivesing/download.go 的 GetDownloadURL)。 + +链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=&songtype= + → { 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 约定 ``|``; +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 需为 "|"(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"]} diff --git a/app/sdk/apis/joox.py b/app/sdk/apis/joox.py new file mode 100644 index 0000000..0620c6e --- /dev/null +++ b/app/sdk/apis/joox.py @@ -0,0 +1,65 @@ +"""JOOX 取流(对应 sdk/apis/joox.ts)。 + +链路:GET cache.api.joox.com/openjoox2/v1/track/?...&secret= + → ``play_url_list`` 首个 http 地址(无则 ``refrain_url``)→ 带 vkey/hdnts 的 CDN 直链。 + +上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 实测), +故取流改用官网当前使用的 openjoox2 通道(签名细节见 ``platforms/joox/music_info.py``)。 + +地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0;登录 cookie 的 +wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。 + +实测(游客态):免费曲目拿到 128k MP3 完整流;VIP 曲目只给 preview/refrain 片段 +(``node_is_preview: true``、``error_code: 9009002``),与上游行为一致。 + +音质档位:游客通道实测只有 128k MP3。JOOX 的 has_hq/has_hifi 标志不代表游客可取, +登录态能否拿到更高档位未验证,故 ``SUPPORT_QUALITYS`` 保守地只声明 128k,不虚报。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.joox.music_info import build_track_url +from app.sdk.platforms.joox.util import JOOX_TIMEOUT, joox_headers, normalize_joox_id + +SUPPORT_QUALITYS: list[str] = ["128k"] + +#: 本平台实际只能返回这一档;返回值里的 type 用它而不是请求的 quality(与 TS 返回 type: '128k' 一致) +_ACTUAL_QUALITY = "128k" + + +async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + """取流:openjoox2 详情 → play_url_list / refrain_url。""" + song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "") + if not song_id: + raise RuntimeError("joox 歌曲 id 无效") + + url = build_track_url(song_id, full_song=True) + res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT) + body = res.body + if res.status_code != 200 or not isinstance(body, dict) or body.get("code"): + msg = "" + if isinstance(body, dict): + msg = str(body.get("msg") or body.get("message") or "") + raise RuntimeError(f"joox 取流失败:{msg or f'HTTP {res.status_code}'}") + + def clean(value: Any) -> str: + return str(value if value is not None else "").replace("&", "&") + + urls = body.get("play_url_list") + play_url = "" + for item in urls if isinstance(urls, list) else []: + candidate = clean(item) + if candidate.startswith("http"): + play_url = candidate + break + if not play_url: + refrain = clean(body.get("refrain_url")) + if refrain.startswith("http"): + play_url = refrain + if not play_url: + raise RuntimeError("无可用播放地址(VIP/版权限制或地区不可用)") + + return {"type": _ACTUAL_QUALITY, "url": play_url} diff --git a/app/sdk/apis/kg.py b/app/sdk/apis/kg.py new file mode 100644 index 0000000..d9c027a --- /dev/null +++ b/app/sdk/apis/kg.py @@ -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} diff --git a/app/sdk/apis/kw.py b/app/sdk/apis/kw.py new file mode 100644 index 0000000..81ffb6e --- /dev/null +++ b/app/sdk/apis/kw.py @@ -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) diff --git a/app/sdk/apis/lr.py b/app/sdk/apis/lr.py new file mode 100644 index 0000000..f83c235 --- /dev/null +++ b/app/sdk/apis/lr.py @@ -0,0 +1,57 @@ +"""懒人听书 URL 获取(对应 sdk/apis/lr.ts,web 通道 getPlayPath,需登录)。 + +接口: https://m.lrts.me/ajax/getPlayPath?entityId=&entityType=3&opType=1§ions=[
]&type=0 +实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话; +成功响应 list[0].path 为音频直链。 + +songmid 为复合 id ``:
``(/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 需为 [:
](见 /album/lr 章节列表)") + cookie = get_lr_cookie() + if not cookie: + raise RuntimeError("懒人听书取流需要登录(POST /login/lr 绑定账号密码)") + + resp = await http_fetch( + f"{PLAY_URL}?entityId={book_id}&entityType=3&opType=1§ions=[{section}]&type=0", + headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/", "Cookie": cookie}, + timeout=15_000, + ) + body = as_dict(resp.body) + if resp.status_code != 200: + raise RuntimeError(f"取流失败(HTTP {resp.status_code})") + items = as_list(body.get("list")) + path = as_dict(items[0]).get("path") if items else None + if not path: + if body.get("status") == 2: + raise RuntimeError(str(body.get("msg") or "收费章节未购买(需账号已购/会员)")) + if "登录" in str(body.get("msg") or ""): + raise RuntimeError("登录态已失效,请重新 POST /login/lr") + raise RuntimeError(str(body.get("msg") or "取流失败")) + return {"type": "128k" if quality == "128k" else "64k", "url": str(path)} diff --git a/app/sdk/apis/me.py b/app/sdk/apis/me.py new file mode 100644 index 0000000..3dcc2a7 --- /dev/null +++ b/app/sdk/apis/me.py @@ -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:" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)。 +""" + +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:" → 首个免费集的 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) diff --git a/app/sdk/apis/mg.py b/app/sdk/apis/mg.py new file mode 100644 index 0000000..b35391d --- /dev/null +++ b/app/sdk/apis/mg.py @@ -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 未返回可访问的音频档位") diff --git a/app/sdk/apis/qt.py b/app/sdk/apis/qt.py new file mode 100644 index 0000000..da2ecb9 --- /dev/null +++ b/app/sdk/apis/qt.py @@ -0,0 +1,42 @@ +"""蜻蜓FM URL 获取(对应 sdk/apis/qt.ts,点播 audiostream 签名直链)。 + +链路(官网 main.js 逆向,2026-10-02 实测): + GET https://audio.qtfm.cn/audiostream/redirect//?access_token=&device_id=MOBILESITE&qingting_id=&t=&sign= + sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外) + 匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3) + 直播电台另有 /live//64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn) + +songmid 为复合 id ``:``(/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 需为 :(见 /album/qt 节目列表)") + return {"type": "128k" if quality == "128k" else "64k", "url": build_program_url(channel_id, program_id)} diff --git a/app/sdk/apis/tx.py b/app/sdk/apis/tx.py new file mode 100644 index 0000000..a5ef499 --- /dev/null +++ b/app/sdk/apis/tx.py @@ -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) diff --git a/app/sdk/apis/wy.py b/app/sdk/apis/wy.py new file mode 100644 index 0000000..f719421 --- /dev/null +++ b/app/sdk/apis/wy.py @@ -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"]} diff --git a/app/sdk/apis/xm.py b/app/sdk/apis/xm.py new file mode 100644 index 0000000..76df5c9 --- /dev/null +++ b/app/sdk/apis/xm.py @@ -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"]} diff --git a/app/sdk/auth/__init__.py b/app/sdk/auth/__init__.py new file mode 100644 index 0000000..f5eeec0 --- /dev/null +++ b/app/sdk/auth/__init__.py @@ -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 diff --git a/app/sdk/auth/bili.py b/app/sdk/auth/bili.py new file mode 100644 index 0000000..ffd9e9f --- /dev/null +++ b/app/sdk/auth/bili.py @@ -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} diff --git a/app/sdk/auth/kg.py b/app/sdk/auth/kg.py new file mode 100644 index 0000000..89662cd --- /dev/null +++ b/app/sdk/auth/kg.py @@ -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")} diff --git a/app/sdk/auth/kg_simulate.py b/app/sdk/auth/kg_simulate.py new file mode 100644 index 0000000..f4065a2 --- /dev/null +++ b/app/sdk/auth/kg_simulate.py @@ -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} diff --git a/app/sdk/auth/lr.py b/app/sdk/auth/lr.py new file mode 100644 index 0000000..b016411 --- /dev/null +++ b/app/sdk/auth/lr.py @@ -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} diff --git a/app/sdk/auth/tx.py b/app/sdk/auth/tx.py new file mode 100644 index 0000000..9b115a8 --- /dev/null +++ b/app/sdk/auth/tx.py @@ -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 {})} diff --git a/app/sdk/auth/wy.py b/app/sdk/auth/wy.py new file mode 100644 index 0000000..9de7d09 --- /dev/null +++ b/app/sdk/auth/wy.py @@ -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() diff --git a/app/sdk/auth_store.py b/app/sdk/auth_store.py new file mode 100644 index 0000000..2000d10 --- /dev/null +++ b/app/sdk/auth_store.py @@ -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() diff --git a/app/sdk/common/__init__.py b/app/sdk/common/__init__.py new file mode 100644 index 0000000..7aa8f6d --- /dev/null +++ b/app/sdk/common/__init__.py @@ -0,0 +1 @@ +"""共享基础设施(请求层、工具函数)。""" diff --git a/app/sdk/common/qrcode.py b/app/sdk/common/qrcode.py new file mode 100644 index 0000000..e8fe696 --- /dev/null +++ b/app/sdk/common/qrcode.py @@ -0,0 +1,36 @@ +"""二维码生成 —— 把登录 URL 渲染成 data URL,供 /login/* 的 qrimg 字段直接展示。 + +对应原 TS 里 `import qrcode from 'qrcode'` 的 `toDataURL` 用法(bili 扫码登录)。 +用 Pillow 后端输出 PNG,再 base64 成 data URL,前端 可直接用。 +""" + +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() diff --git a/app/sdk/common/request.py b/app/sdk/common/request.py new file mode 100644 index 0000000..f00a539 --- /dev/null +++ b/app/sdk/common/request.py @@ -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 diff --git a/app/sdk/common/utils.py b/app/sdk/common/utils.py new file mode 100644 index 0000000..be449d8 --- /dev/null +++ b/app/sdk/common/utils.py @@ -0,0 +1,158 @@ +"""通用工具函数(对应原 sdk/common/utils.ts 与 sdk/utils.ts)。""" + +from __future__ import annotations + +import hashlib +import math +import re +import time +from collections.abc import Iterable, Mapping +from datetime import datetime +from typing import Any + + +def to_md5(text: str) -> str: + return hashlib.md5(text.encode("utf-8")).hexdigest() + + +def as_dict(value: Any) -> dict[str, Any]: + """上游 ``x?.field ?? {}`` 的安全取法:非 dict 一律当空 dict。""" + return value if isinstance(value, dict) else {} + + +def as_list(value: Any) -> list[Any]: + """上游 ``x ?? []`` 的安全取法:非 list 一律当空列表。""" + return value if isinstance(value, list) else [] + + +def size_formate(size: float | int | None) -> str: + """将字节数格式化为可读字符串。""" + if not size: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + number = math.floor(math.log(size) / math.log(1024)) + number = min(number, len(units) - 1) + return f"{size / math.pow(1024, number):.2f} {units[number]}" + + +def format_play_time(seconds: float | int | str | None) -> str: + """秒数 → mm:ss;0 返回 --/--。""" + try: + value = float(seconds) # type: ignore[arg-type] + except (TypeError, ValueError): + return "--/--" + if math.isnan(value) or value < 0: + value = 0 + minutes = int(value // 60) + secs = int(value % 60) + if minutes == 0 and secs == 0: + return "--/--" + return f"{minutes:02d}:{secs:02d}" + + +def date_format(value: Any, fmt: str = "Y-M-D h:m:s") -> str: + """时间对象/时间戳/字符串 → 日期字符串(Y/M/D/h/m/s 占位)。""" + if not value: + return "" + if isinstance(value, datetime): + date = value + elif isinstance(value, (int, float)): + date = datetime.fromtimestamp(value / 1000 if value > 1e11 else value) + elif isinstance(value, str): + raw = value + if "T" not in raw: + raw = raw.split(".")[0].replace("-", "/") + parsed: datetime | None = None + for pattern in ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"): + try: + parsed = datetime.strptime(raw[: len(pattern) + 10], pattern) + break + except ValueError: + continue + if parsed is None: + try: + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return "" + date = parsed + else: + return "" + return ( + fmt.replace("Y", str(date.year)) + .replace("M", f"{date.month:02d}") + .replace("D", f"{date.day:02d}") + .replace("h", f"{date.hour:02d}") + .replace("m", f"{date.minute:02d}") + .replace("s", f"{date.second:02d}") + ) + + +def date_format2(timestamp_ms: float | int) -> str: + """相对时间格式化(用于评论时间)。""" + differ = int((time.time() * 1000 - float(timestamp_ms)) / 1000) + if differ < 60: + return f"{differ}秒前" + if differ < 3600: + return f"{differ // 60}分钟前" + if differ < 86400: + return f"{differ // 3600}小时前" + if differ < 2592000: + return f"{differ // 86400}天前" + return date_format(timestamp_ms) + + +def format_play_count(num: float | int | None) -> str: + if num is None: + return "0" + if num > 100000000: + return f"{int(num / 10000000) / 10}亿" + if num > 10000: + return f"{int(num / 1000) / 10}万" + return str(num) + + +_ENTITY_MAP = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", + "'": "'", + " ": " ", +} + + +def decode_name(text: Any = "") -> str: + """解码 HTML 实体。""" + if not text: + return "" + if not isinstance(text, str): + text = str(text) + for entity, char in _ENTITY_MAP.items(): + text = text.replace(entity, char) + return re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text) + + +def format_singer_name( + singers: Iterable[Mapping[str, Any]] | str | None, + name_key: str = "name", + join: str = "、", +) -> str: + """歌手数组或字符串 → 展示名(数组按 name_key 提取后 join)。""" + if isinstance(singers, (list, tuple)): + names = [str(item[name_key]) for item in singers if isinstance(item, Mapping) and item.get(name_key)] + return decode_name(join.join(names)) + return decode_name(str(singers or "")) + + +def pick(data: Any, *keys: str, default: Any = None) -> Any: + """沿键路径取值(dict 缺失返回 default),迁移时统一空值访问。""" + cur = data + for key in keys: + if isinstance(cur, Mapping): + cur = cur.get(key) + else: + return default + if cur is None: + return default + return cur diff --git a/app/sdk/platforms/__init__.py b/app/sdk/platforms/__init__.py new file mode 100644 index 0000000..65af44c --- /dev/null +++ b/app/sdk/platforms/__init__.py @@ -0,0 +1,4 @@ +"""平台包集合。每个子包导出 `MODULE: Platform`。 + +目录名与平台 id 的差异:5sing → fivesing(Python 标识符不能以数字开头)。 +""" diff --git a/app/sdk/platforms/_base.py b/app/sdk/platforms/_base.py new file mode 100644 index 0000000..a8b98f4 --- /dev/null +++ b/app/sdk/platforms/_base.py @@ -0,0 +1,110 @@ +"""平台模块契约 —— 各平台包导出 `MODULE = Platform(...)`。 + +对应原 TS 的 `sdk//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"" + + +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 diff --git a/app/sdk/platforms/apple/__init__.py b/app/sdk/platforms/apple/__init__.py new file mode 100644 index 0000000..8bc562a --- /dev/null +++ b/app/sdk/platforms/apple/__init__.py @@ -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, +) diff --git a/app/sdk/platforms/apple/album.py b/app/sdk/platforms/apple/album.py new file mode 100644 index 0000000..2a1e006 --- /dev/null +++ b/app/sdk/platforms/apple/album.py @@ -0,0 +1,89 @@ +"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。 + +详情:amp-api /v1/catalog//albums/?extend=extendedAssetUrls + → data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进) +链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album// 链接。 +""" + +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"] diff --git a/app/sdk/platforms/apple/lyric.py b/app/sdk/platforms/apple/lyric.py new file mode 100644 index 0000000..3c86bd6 --- /dev/null +++ b/app/sdk/platforms/apple/lyric.py @@ -0,0 +1,45 @@ +"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 apple.go GetLyrics)。 + +通道:amp-api /v1/catalog//songs/?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 "" diff --git a/app/sdk/platforms/apple/music_search.py b/app/sdk/platforms/apple/music_search.py new file mode 100644 index 0000000..03c6a42 --- /dev/null +++ b/app/sdk/platforms/apple/music_search.py @@ -0,0 +1,90 @@ +"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。 + +通道:amp-api /v1/catalog//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", + } diff --git a/app/sdk/platforms/apple/parse.py b/app/sdk/platforms/apple/parse.py new file mode 100644 index 0000000..9ab9bb5 --- /dev/null +++ b/app/sdk/platforms/apple/parse.py @@ -0,0 +1,34 @@ +"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 apple.go Parse / ParseAlbum / ParsePlaylist)。 + +song: https://music.apple.com/us/album//?i=(?i= 优先)或裸数字 song id +album: https://music.apple.com/us/album// 或裸数字 album id +playlist: https://music.apple.com/us/playlist//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) diff --git a/app/sdk/platforms/apple/playlist.py b/app/sdk/platforms/apple/playlist.py new file mode 100644 index 0000000..4fafa85 --- /dev/null +++ b/app/sdk/platforms/apple/playlist.py @@ -0,0 +1,177 @@ +"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。 + +详情:amp-api /v1/catalog//playlists/?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//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", +] diff --git a/app/sdk/platforms/apple/util.py b/app/sdk/platforms/apple/util.py new file mode 100644 index 0000000..1b1136e --- /dev/null +++ b/app/sdk/platforms/apple/util.py @@ -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 段中找 // 或 /。""" + 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//songs/)。""" + 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="._-") diff --git a/app/sdk/platforms/bd/__init__.py b/app/sdk/platforms/bd/__init__.py new file mode 100644 index 0000000..3600817 --- /dev/null +++ b/app/sdk/platforms/bd/__init__.py @@ -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, +) diff --git a/app/sdk/platforms/bd/album.py b/app/sdk/platforms/bd/album.py new file mode 100644 index 0000000..0b36a33 --- /dev/null +++ b/app/sdk/platforms/bd/album.py @@ -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/`。 +""" + +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("无效的千千专辑链接") diff --git a/app/sdk/platforms/bd/lyric.py b/app/sdk/platforms/bd/lyric.py new file mode 100644 index 0000000..711ef36 --- /dev/null +++ b/app/sdk/platforms/bd/lyric.py @@ -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": ""} diff --git a/app/sdk/platforms/bd/music_info.py b/app/sdk/platforms/bd/music_info.py new file mode 100644 index 0000000..4e4e1fe --- /dev/null +++ b/app/sdk/platforms/bd/music_info.py @@ -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, + } diff --git a/app/sdk/platforms/bd/music_search.py b/app/sdk/platforms/bd/music_search.py new file mode 100644 index 0000000..008864e --- /dev/null +++ b/app/sdk/platforms/bd/music_search.py @@ -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"} diff --git a/app/sdk/platforms/bd/parse.py b/app/sdk/platforms/bd/parse.py new file mode 100644 index 0000000..ec534ac --- /dev/null +++ b/app/sdk/platforms/bd/parse.py @@ -0,0 +1,48 @@ +"""千千音乐链接解析(对应 sdk/bd/parse.ts)。 + +歌曲:`music.91q.com/song/`(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)。 +专辑:`music.91q.com/album/` / `?albumAssetCode=` / `?albumid=`。 +歌单:`music.91q.com/songlist|tracklist|playlist/` / `?*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) diff --git a/app/sdk/platforms/bd/playlist.py b/app/sdk/platforms/bd/playlist.py new file mode 100644 index 0000000..60a03ce --- /dev/null +++ b/app/sdk/platforms/bd/playlist.py @@ -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("无效的千千歌单链接") diff --git a/app/sdk/platforms/bd/util.py b/app/sdk/platforms/bd/util.py new file mode 100644 index 0000000..939abe7 --- /dev/null +++ b/app/sdk/platforms/bd/util.py @@ -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}" diff --git a/app/sdk/platforms/bili/__init__.py b/app/sdk/platforms/bili/__init__.py new file mode 100644 index 0000000..9850c92 --- /dev/null +++ b/app/sdk/platforms/bili/__init__.py @@ -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 约定 ``|``。 +""" + +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, +) diff --git a/app/sdk/platforms/bili/lyric.py b/app/sdk/platforms/bili/lyric.py new file mode 100644 index 0000000..e691e01 --- /dev/null +++ b/app/sdk/platforms/bili/lyric.py @@ -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": ""} diff --git a/app/sdk/platforms/bili/music_search.py b/app/sdk/platforms/bili/music_search.py new file mode 100644 index 0000000..9e6ccff --- /dev/null +++ b/app/sdk/platforms/bili/music_search.py @@ -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", + } diff --git a/app/sdk/platforms/bili/parse.py b/app/sdk/platforms/bili/parse.py new file mode 100644 index 0000000..a389f2b --- /dev/null +++ b/app/sdk/platforms/bili/parse.py @@ -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) diff --git a/app/sdk/platforms/bili/pic.py b/app/sdk/platforms/bili/pic.py new file mode 100644 index 0000000..3a4526c --- /dev/null +++ b/app/sdk/platforms/bili/pic.py @@ -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 "" diff --git a/app/sdk/platforms/bili/playlist.py b/app/sdk/platforms/bili/playlist.py new file mode 100644 index 0000000..541bb75 --- /dev/null +++ b/app/sdk/platforms/bili/playlist.py @@ -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:::`` 或 ``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:::`` 构造歌单详情(上游 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:::、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站不支持歌单分类(上游未实现)") diff --git a/app/sdk/platforms/bili/util.py b/app/sdk/platforms/bili/util.py new file mode 100644 index 0000000..30391c6 --- /dev/null +++ b/app/sdk/platforms/bili/util.py @@ -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'|', "", str(title)) + + +def parse_song_id(songmid: Any) -> dict[str, str]: + """songmid 约定:``|``(与上游 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:`` / ``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 diff --git a/app/sdk/platforms/fivesing/__init__.py b/app/sdk/platforms/fivesing/__init__.py new file mode 100644 index 0000000..d2db61f --- /dev/null +++ b/app/sdk/platforms/fivesing/__init__.py @@ -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 约定 ``|``(如 "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, +) diff --git a/app/sdk/platforms/fivesing/lyric.py b/app/sdk/platforms/fivesing/lyric.py new file mode 100644 index 0000000..dd53471 --- /dev/null +++ b/app/sdk/platforms/fivesing/lyric.py @@ -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 = "|",从 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": ""} diff --git a/app/sdk/platforms/fivesing/music_info.py b/app/sdk/platforms/fivesing/music_info.py new file mode 100644 index 0000000..0f7042b --- /dev/null +++ b/app/sdk/platforms/fivesing/music_info.py @@ -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) diff --git a/app/sdk/platforms/fivesing/music_search.py b/app/sdk/platforms/fivesing/music_search.py new file mode 100644 index 0000000..b274ca4 --- /dev/null +++ b/app/sdk/platforms/fivesing/music_search.py @@ -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 约定仍为 "|")。 +时长:上游用 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"} diff --git a/app/sdk/platforms/fivesing/parse.py b/app/sdk/platforms/fivesing/parse.py new file mode 100644 index 0000000..4359c94 --- /dev/null +++ b/app/sdk/platforms/fivesing/parse.py @@ -0,0 +1,25 @@ +"""5sing 链接解析(对应 sdk/5sing/parse.ts)。 + +歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetch_song_info(直链 + newget 元数据,无直链即报错) +歌单: /{userId}/dj/.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) diff --git a/app/sdk/platforms/fivesing/playlist.py b/app/sdk/platforms/fivesing/playlist.py new file mode 100644 index 0000000..4b05af6 --- /dev/null +++ b/app/sdk/platforms/fivesing/playlist.py @@ -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 解析
  • 块 + ③ 数字 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 提取歌曲。 + + 块正则 ``
  • ([\\s\\S]*?)
  • ``;块内 href 提取 yc/fc/bz 与 id, + s_soner 提取歌手(上游的拼写错误 soner 保留)。 + """ + blocks = re.findall(r'
  • ([\s\S]*?)
  • ', 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"[^>]*>([^<]+)') + artist_re = re.compile(r'class="s_soner[^"]*".*?>([^<]+)') + + 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, + } diff --git a/app/sdk/platforms/fivesing/util.py b/app/sdk/platforms/fivesing/util.py new file mode 100644 index 0000000..5b934e8 --- /dev/null +++ b/app/sdk/platforms/fivesing/util.py @@ -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: + """去掉搜索结果里 ```` 高亮标签。""" + return str(text if text is not None else "").replace('', "").replace("", "").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]: + """解析歌曲的 ``|`` 约定(兼容只给 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, "") diff --git a/app/sdk/platforms/joox/__init__.py b/app/sdk/platforms/joox/__init__.py new file mode 100644 index 0000000..683ac0a --- /dev/null +++ b/app/sdk/platforms/joox/__init__.py @@ -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, +) diff --git a/app/sdk/platforms/joox/_probe.py b/app/sdk/platforms/joox/_probe.py new file mode 100644 index 0000000..e69de29 diff --git a/app/sdk/platforms/joox/album.py b/app/sdk/platforms/joox/album.py new file mode 100644 index 0000000..dec897b --- /dev/null +++ b/app/sdk/platforms/joox/album.py @@ -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//tracks?country=hk&lang=zh_TW&index=&num=&secret= + (web 播放器通道,需 md5 签名;实测 2026-10 HTTP 200) +兜底:www.joox.com//album/ 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") diff --git a/app/sdk/platforms/joox/lyric.py b/app/sdk/platforms/joox/lyric.py new file mode 100644 index 0000000..5818878 --- /dev/null +++ b/app/sdk/platforms/joox/lyric.py @@ -0,0 +1,92 @@ +"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。 + +主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=&country=sg&lang=zh_cn + 响应形如 ``MusicJsonCallback({"code":0,"lyric":""})``,lyric 为 base64 UTF-8 歌词。 + 实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。 +兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。 +解码:base64 → UTF-8 → HTML 实体(& 等)→ ``{ lyric, tlyric: "" }``。 + +QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。 +""" + +from __future__ import annotations + +import json +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.joox.music_info import build_track_url +from app.sdk.platforms.joox.util import ( + JOOX_TIMEOUT, + decode_joox_lyric, + encode_uri_component, + first_non_empty, + joox_headers, + normalize_joox_id, +) + +LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric" + + +def parse_lyric_payload(body: Any) -> dict[str, Any] | None: + """解析 MusicJsonCallback(...) / 裸 JSON 包裹。""" + if isinstance(body, dict): + return body + text = str(body if body is not None else "") + index = text.find("MusicJsonCallback(") + if index >= 0: + text = text[index + len("MusicJsonCallback(") :] + text = text.rstrip() + if text.endswith(";"): + text = text[:-1].rstrip() + if text.endswith(")"): + text = text[:-1].rstrip() + try: + parsed = json.loads(text) + except ValueError: + return None + return parsed if isinstance(parsed, dict) else None + + +def parse_legacy_lyric(status_code: int, body: Any) -> str: + """上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。""" + if status_code != 200: + raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})") + payload = parse_lyric_payload(body) + if not payload: + raise RuntimeError("joox 歌词解析失败") + raw = str(first_non_empty(payload.get("lyric"), "")) + if int(payload.get("code") or 0) != 0 or not raw: + raise RuntimeError("joox 歌词不存在或为空") + return decode_joox_lyric(raw) + + +async def get_legacy_lyric(song_id: str) -> str: + """上游 GetLyrics 通道(仅旧接口,测试/内部用)。""" + url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn" + res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT) + return parse_legacy_lyric(res.status_code, res.body) + + +async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]: + """歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。""" + song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "") + if not song_id: + raise RuntimeError("joox 歌曲 id 无效") + + # 1) 上游 web_lyric 通道 + legacy_err: Exception | None = None + try: + url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn" + res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT) + return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""} + except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底 + legacy_err = err + + # 2) 兜底:openjoox2 详情 lrc_content(base64) + res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT) + body = res.body if isinstance(res.body, dict) else {} + raw = str(first_non_empty(body.get("lrc_content"), "")) + if res.status_code != 200 or not raw: + raise legacy_err or RuntimeError("joox 歌词不存在或为空") + return {"lyric": decode_joox_lyric(raw), "tlyric": ""} diff --git a/app/sdk/platforms/joox/music_info.py b/app/sdk/platforms/joox/music_info.py new file mode 100644 index 0000000..cc09f75 --- /dev/null +++ b/app/sdk/platforms/joox/music_info.py @@ -0,0 +1,103 @@ +"""JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)。 + +对应 sdk/joox/musicInfo.ts。 + +端点:GET https://cache.api.joox.com/openjoox2/v1/track/ + ?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=][&usk=]&secret= + +签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..') + * 参数按**插入序**参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。 + * 签名串与真正发出的参数来自同一次序列化(同一份 params dict + 同一 encoder),不做二次拼接。 + * 实测(2026-10):不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"}; + 按上式 → 200,返回 play_url_list / refrain_url / lrc_content / qrc_content / vip_flag 等。 + +上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 前),本模块为其替代。 +``fs=1`` 请求完整曲目(游客仍可能拿到 preview/refrain),``im`` 为 mobile 标记。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.joox.util import ( + JOOX_TIMEOUT, + cookie_value, + encode_uri_component, + joox_headers, + joox_login_cookie, + joox_sign, + normalize_joox_id, + normalize_joox_lang, +) + +JOOX_OPENJOOX2 = "https://cache.api.joox.com/openjoox2/v1" + + +def build_track_url( + raw_id: Any, + country: str = "", + lang: str = "", + full_song: bool = False, + is_mobile: bool = False, +) -> str: + """构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk)。""" + track_id = str(normalize_joox_id(raw_id)).replace("/", "_") + params: dict[str, Any] = { + "country": country or "hk", + "lang": normalize_joox_lang(lang or "zh_TW"), + "lyric": 1, + "fs": 1 if full_song else 0, + "im": 1 if is_mobile else 0, + } + # 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项) + login_cookie = joox_login_cookie() + uid = cookie_value(login_cookie, "wmid") if login_cookie else "" + usk = cookie_value(login_cookie, "session_key") if login_cookie else "" + if uid: + params["uid"] = uid + if usk: + params["usk"] = usk + # 签名时包含 id(与 web 播放器一致),发布 query 时 id 走路径、不进 query + secret = joox_sign({**params, "id": track_id}) + query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in {**params, "secret": secret}.items()) + return f"{JOOX_OPENJOOX2}/track/{track_id}?{query}" + + +async def get_track_detail( + raw_id: Any, + country: str = "", + lang: str = "", + full_song: bool = False, + is_mobile: bool = False, +) -> dict[str, Any]: + """歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现)。""" + track_id = normalize_joox_id(raw_id) + if not track_id: + raise RuntimeError("joox 歌曲 id 无效") + url = build_track_url(track_id.replace("/", "_"), country, lang, full_song, is_mobile) + res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT) + body = res.body + if res.status_code != 200 or not isinstance(body, dict) or body.get("code"): + msg = "" + if isinstance(body, dict): + msg = str(body.get("msg") or body.get("message") or "") + raise RuntimeError(f"joox 歌曲详情获取失败:{msg or f'HTTP {res.status_code}'}") + return body + + +def pick_track_url(detail: Any) -> str: + """从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 & 实体。""" + if not isinstance(detail, dict): + return "" + + def clean(value: Any) -> str: + return str(value if value is not None else "").replace("&", "&") + + urls = detail.get("play_url_list") + for url in urls if isinstance(urls, list) else []: + text = clean(url) + if text.startswith("http"): + return text + refrain = clean(detail.get("refrain_url")) + return refrain if refrain.startswith("http") else "" diff --git a/app/sdk/platforms/joox/music_search.py b/app/sdk/platforms/joox/music_search.py new file mode 100644 index 0000000..71169eb --- /dev/null +++ b/app/sdk/platforms/joox/music_search.py @@ -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= +返回 ``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", + } diff --git a/app/sdk/platforms/joox/parse.py b/app/sdk/platforms/joox/parse.py new file mode 100644 index 0000000..07919a5 --- /dev/null +++ b/app/sdk/platforms/joox/parse.py @@ -0,0 +1,43 @@ +"""JOOX 链接解析(对应 sdk/joox/parse.ts)。 + +歌曲:joox.com//single/ 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链) +专辑:joox.com/.../album/、h_activity_id=、albumid= → album.get_album_detail +歌单:joox.com/.../playlist/、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) diff --git a/app/sdk/platforms/joox/playlist.py b/app/sdk/platforms/joox/playlist.py new file mode 100644 index 0000000..800ea2f --- /dev/null +++ b/app/sdk/platforms/joox/playlist.py @@ -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//tracks?...&secret= + (上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留) + 兜底:www.joox.com/{id,sg,hk}/playlist/ SSR ``__NEXT_DATA__`` 的 + props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体) + 分类:www.joox.com/sg/playlist(302 → / 首页)``__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/、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 diff --git a/app/sdk/platforms/joox/song.py b/app/sdk/platforms/joox/song.py new file mode 100644 index 0000000..3b47583 --- /dev/null +++ b/app/sdk/platforms/joox/song.py @@ -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 diff --git a/app/sdk/platforms/joox/util.py b/app/sdk/platforms/joox/util.py new file mode 100644 index 0000000..545a41f --- /dev/null +++ b/app/sdk/platforms/joox/util.py @@ -0,0 +1,267 @@ +"""JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取(对应 sdk/joox/util.ts)。 + +背景(与 TS 注释一致,2026-10 实测): + * JOOX 是东南亚服务,www.joox.com 会按来源 IP 返回「Sorry, JOOX is not available in this + location」;上游通过伪造 ``X-Forwarded-For`` 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。 + * 新版 web 播放器走 ``cache.api.joox.com/openjoox2/v1/*``,**必须**带 secret 签名: + secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent) + 密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。 + 实测:不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};按上式 → 200。 + * 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2), + 但 web_lyric 仍可用(歌词首选通道)。 + * 登录 cookie 里的 wmid/session_key 在签名参数里作为 uid/usk 参与(VIP 完整曲目依赖它)。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +from typing import Any +from urllib.parse import quote, unquote, urljoin + +from app.sdk.auth_store import get_credentials +from app.sdk.common.request import http_fetch + +JOOX_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36" +) +"""上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie)。""" +JOOX_DEFAULT_COOKIE = "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;" +"""上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区拦截)。""" +JOOX_XFF = "36.73.34.109" +"""openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey)。""" +JOOX_SECRET_KEY = "Jo0x@t3Nc3nT" +"""平台统一超时(毫秒),与 TS 各处 timeout: 20000 一致。""" +JOOX_TIMEOUT = 20000 + + +def md5_hex(text: str) -> str: + return hashlib.md5(text.encode("utf-8")).hexdigest() + + +def encode_uri_component(value: Any) -> str: + """对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。""" + return quote(str(value), safe="!~*'()") + + +def joox_sign(params: dict[str, Any]) -> str: + """openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)。 + + **键序即 dict 插入序**(服务端按同序校验),不要重排参数 —— 任何 sorted() 都会破坏签名。 + """ + pairs = [f"{key}={encode_uri_component(value)}" for key, value in params.items()] + return md5_hex(JOOX_SECRET_KEY + "&".join(pairs)) + + +def joox_login_cookie() -> str: + """绑定到 auth_store 的登录 cookie(未登录返回空串,不回退游客 cookie)。""" + cred = get_credentials("joox") + if not cred: + return "" + return str(cred.get("cookie") or "") + + +def joox_cookie() -> str: + """请求用 cookie(登录态优先;未登录回退上游游客 cookie)。""" + return joox_login_cookie() or JOOX_DEFAULT_COOKIE + + +def cookie_value(cookie: str, name: str) -> str: + """从 cookie 串里取一个字段。""" + match = re.search(rf"(?:^|;\s*){re.escape(name)}=([^;]*)", str(cookie or "")) + return match.group(1).strip() if match else "" + + +def joox_headers(extra: dict[str, Any] | None = None) -> dict[str, str]: + """平台公共请求头(含地区伪装与 Cookie)。""" + headers = { + "User-Agent": JOOX_UA, + "Cookie": joox_cookie(), + "X-Forwarded-For": JOOX_XFF, + "Referer": "https://www.joox.com/", + "Accept": "application/json, text/plain, */*", + } + if extra: + headers.update({str(key): str(value) for key, value in extra.items()}) + return headers + + +def normalize_joox_lang(lang: str | None) -> str: + """语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN)。""" + if lang in ("zh_hk", "zh_mo"): + return "zh_TW" + if lang == "zh_cn": + return "zh_CN" + return lang or "zh_TW" + + +def normalize_joox_id(raw: Any) -> str: + """ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +)。""" + normalized = str(raw if raw is not None else "").strip() + if not normalized: + return "" + if "%" in normalized: + try: + normalized = unquote(normalized, errors="strict") + except (UnicodeDecodeError, ValueError): + # 上游 PathUnescape/QueryUnescape 失败时保留原值 + pass + normalized = normalized.replace(" ", "+") + return normalized + + +def joox_song_link(raw_id: Any) -> str: + return f"https://www.joox.com/hk/single/{normalize_joox_id(raw_id)}" + + +def joox_album_link(raw_id: Any) -> str: + return f"https://www.joox.com/hk/album/{normalize_joox_id(raw_id)}" + + +def joox_regional_playlist_link(region: str, raw_id: Any) -> str: + return f"https://www.joox.com/{region}/playlist/{normalize_joox_id(raw_id)}" + + +def joox_playlist_link(raw_id: Any) -> str: + return joox_regional_playlist_link("id", raw_id) + + +def joox_playlist_links(raw_id: Any) -> list[str]: + """歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用)。""" + return [joox_regional_playlist_link(region, raw_id) for region in ("id", "sg", "hk")] + + +def pick_joox_image(images: Any) -> str: + """封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空)。""" + items = images if isinstance(images, list) else [] + for width in (300, 1000, 100): + for image in items: + if not isinstance(image, dict): + continue + url = str(image.get("url") or "").strip() + if to_int(image.get("width")) == width and url: + return url + for image in items: + if not isinstance(image, dict): + continue + url = str(image.get("url") or "").strip() + if url: + return url + return "" + + +def joox_artists(items: Any) -> str: + """歌手拼接(上游 joinJooxArtists,分隔符 ' / ')。""" + listing = items if isinstance(items, list) else [] + names = [str(entry.get("name") or "").strip() for entry in listing if isinstance(entry, dict)] + return " / ".join(name for name in names if name) + + +def first_non_empty(*values: Any) -> Any: + for value in values: + if value is None: + continue + if str(value).strip() != "": + return value + return "" + + +def first_non_zero(*values: Any) -> int | float: + for value in values: + number = to_number(value) + if number is not None and number > 0: + return number + return 0 + + +def to_number(value: Any) -> float | None: + """对齐 JS Number():有限数值返回 float,其余返回 None。""" + if isinstance(value, bool): + return 1.0 if value else 0.0 + if isinstance(value, (int, float)): + number = float(value) + elif isinstance(value, str): + text = value.strip() + if not text: + return 0.0 + try: + number = float(text) + except ValueError: + return None + else: + return None + if number != number or number in (float("inf"), float("-inf")): + return None + return number + + +def to_int(value: Any) -> int | None: + number = to_number(value) + return None if number is None else int(number) + + +def decode_joox_base64_text(value: Any) -> str: + """base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充)。""" + raw = str(value if value is not None else "").strip() + if not raw: + return "" + normalized = raw.replace("-", "+").replace("_", "/") + padded = normalized + "=" * ((4 - len(normalized) % 4) % 4) + try: + text = base64.b64decode(padded, validate=False).decode("utf-8", errors="replace") + except (ValueError, TypeError): + return raw + # 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义) + return raw if "\ufffd" in text else text.strip() + + +def decode_html_entities(text: Any) -> str: + """HTML 实体解码(歌词文本兜底)。""" + return ( + str(text if text is not None else "") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'") + .replace("�*39;", "'") + .replace(" ", " ") + ) + + +def decode_joox_lyric(value: Any) -> str: + """歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底)。""" + return decode_html_entities(decode_joox_base64_text(value)) + + +_NEXT_DATA_RE = re.compile(r']*id="__NEXT_DATA__"[^>]*>([\s\S]*?)') + + +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 页面重定向次数过多") diff --git a/app/sdk/platforms/kg/__init__.py b/app/sdk/platforms/kg/__init__.py new file mode 100644 index 0000000..8a521f3 --- /dev/null +++ b/app/sdk/platforms/kg/__init__.py @@ -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, +) diff --git a/app/sdk/platforms/kg/album.py b/app/sdk/platforms/kg/album.py new file mode 100644 index 0000000..b0197db --- /dev/null +++ b/app/sdk/platforms/kg/album.py @@ -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/.html、yy/album/single/.html、album/.html、albumid=。""" + 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("无效的酷狗专辑链接") diff --git a/app/sdk/platforms/kg/comment.py b/app/sdk/platforms/kg/comment.py new file mode 100644 index 0000000..f2f0f55 --- /dev/null +++ b/app/sdk/platforms/kg/comment.py @@ -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 [])} diff --git a/app/sdk/platforms/kg/hot_search.py b/app/sdk/platforms/kg/hot_search.py new file mode 100644 index 0000000..6734f58 --- /dev/null +++ b/app/sdk/platforms/kg/hot_search.py @@ -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 [])} diff --git a/app/sdk/platforms/kg/krc_decode.py b/app/sdk/platforms/kg/krc_decode.py new file mode 100644 index 0000000..85aca7b --- /dev/null +++ b/app/sdk/platforms/kg/krc_decode.py @@ -0,0 +1,102 @@ +"""酷狗 KRC 歌词解密(对应 sdk/kg/krcDecode.ts)。 + +密文是 base64 → 去掉 4 字节头 → 与 16 字节固定 key 循环异或 → zlib inflate。 +解密后是带 `[start,duration]` 行级标签与 `` 字级标签的 KRC 文本, +另外 `[language:]` 行里藏着 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"] diff --git a/app/sdk/platforms/kg/leaderboard.py b/app/sdk/platforms/kg/leaderboard.py new file mode 100644 index 0000000..25a3788 --- /dev/null +++ b/app/sdk/platforms/kg/leaderboard.py @@ -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" diff --git a/app/sdk/platforms/kg/lyric.py b/app/sdk/platforms/kg/lyric.py new file mode 100644 index 0000000..0255e1d --- /dev/null +++ b/app/sdk/platforms/kg/lyric.py @@ -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"]) diff --git a/app/sdk/platforms/kg/music_info.py b/app/sdk/platforms/kg/music_info.py new file mode 100644 index 0000000..bef6689 --- /dev/null +++ b/app/sdk/platforms/kg/music_info.py @@ -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) diff --git a/app/sdk/platforms/kg/music_search.py b/app/sdk/platforms/kg/music_search.py new file mode 100644 index 0000000..5518c46 --- /dev/null +++ b/app/sdk/platforms/kg/music_search.py @@ -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", + } diff --git a/app/sdk/platforms/kg/parse.py b/app/sdk/platforms/kg/parse.py new file mode 100644 index 0000000..b516791 --- /dev/null +++ b/app/sdk/platforms/kg/parse.py @@ -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) diff --git a/app/sdk/platforms/kg/pic.py b/app/sdk/platforms/kg/pic.py new file mode 100644 index 0000000..4ecafb1 --- /dev/null +++ b/app/sdk/platforms/kg/pic.py @@ -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 diff --git a/app/sdk/platforms/kg/playlist.py b/app/sdk/platforms/kg/playlist.py new file mode 100644 index 0000000..f0405b7 --- /dev/null +++ b/app/sdk/platforms/kg/playlist.py @@ -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/.html 与 songlist// 优先,其余交给 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) diff --git a/app/sdk/platforms/kg/singer.py b/app/sdk/platforms/kg/singer.py new file mode 100644 index 0000000..6c3ca6f --- /dev/null +++ b/app/sdk/platforms/kg/singer.py @@ -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"), + } diff --git a/app/sdk/platforms/kg/song_list.py b/app/sdk/platforms/kg/song_list.py new file mode 100644 index 0000000..37c9dbf --- /dev/null +++ b/app/sdk/platforms/kg/song_list.py @@ -0,0 +1,844 @@ +"""酷狗歌单广场与歌单解析(对应 sdk/kg/songList.ts,31KB 的最大模块)。 + +三个入口: + get_list(sort_id, tag_id, page) —— 歌单广场(special/getSpecial + 详情 + 推荐) + get_list_detail(id, page) —— 歌单解析,覆盖 special/single、酷狗码、gcid、 + share chain、zlist、global_collection_id、网页链接 + search(keyword, page, limit) —— 歌单搜索(msearchretry v3 search/special) + +原实现的 `_requestObj*` 取消句柄在 Python 侧无对应物(http_fetch 自身不可取消), +已省略;其余降级链、重试次数、错误文案与原实现保持一致。 +""" + +from __future__ import annotations + +import asyncio +import json +import re +import time +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, size_formate, to_md5 +from app.sdk.platforms.kg.util import create_http_fetch, signature_params + +LIST_DETAIL_LIMIT = 10000 + +SORT_LIST: list[dict[str, str]] = [ + {"name": "推荐", "id": "5"}, + {"name": "最热", "id": "6"}, + {"name": "最新", "id": "7"}, + {"name": "热藏", "id": "3"}, + {"name": "飙升", "id": "8"}, +] + +_LIST_DATA_EXP = re.compile(r"global\.data = (\[.+\]);") +_LIST_INFO_EXP = re.compile(r'global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};') +_LIST_DETAIL_LINK_EXP = re.compile(r"^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)") + +# chain → 分享信息缓存(原实现是 Map,跨请求复用同一进程缓存) +_CACHE: dict[str, Any] = {} +_CURRENT_TAG_INFO: dict[str, Any] = {"id": None, "info": None} + + +def _handle_signature(id: Any, page: int, limit: int) -> str: + """H5 签名:业务参数按键排序拼接后以 key 包裹取 MD5(与 signature_params 同构)。""" + params = { + "srcappid": "2919", + "clientver": "20000", + "clienttime": int(time.time() * 1000), + "mid": "0", + "uuid": "0", + "dfid": "-", + "appid": 1058, + "type": 0, + "module": "playlist", + "page": page, + "pagesize": limit, + "specialid": id, + } + key = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt" + sorted_params = "".join(f"{k}={params[k]}" for k in sorted(params)) + return to_md5(f"{key}{sorted_params}{key}") + + +def parse_html_desc(html: str) -> str | None: + prefix = '
    ' + index = html.find(prefix) + if index < 0: + return None + after = html[index + len(prefix) :] + end = after.find("
    ") + if end < 0: + return None + return decode_name(after[:end]) + + +def _filter_info_hot_tag(raw: dict[str, Any]) -> list[dict[str, Any]]: + if raw.get("status") != 1: + return [] + return [ + {"id": tag.get("special_id"), "name": tag.get("special_name"), "source": "kg"} + for tag in (raw.get("data") or {}).values() + ] + + +def _filter_tag_info(raw: dict[str, Any]) -> list[dict[str, Any]]: + return [ + { + "name": name, + "list": [ + { + "parent_id": tag.get("parent_id"), + "parent_name": tag.get("pname"), + "id": tag.get("id"), + "name": tag.get("name"), + "source": "kg", + } + for tag in (raw[name].get("data") or []) + ], + } + for name in raw + ] + + +def _filter_list(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "play_count": item.get("total_play_count") or format_play_count(item.get("play_count")), + "id": "id_" + str(item.get("specialid")), + "author": item.get("nickname"), + "name": item.get("specialname"), + "time": date_format(item.get("publish_time") or item.get("publishtime"), "Y-M-D"), + "img": item.get("img") or item.get("imgurl"), + "total": item.get("songcount"), + "grade": item.get("grade"), + "desc": item.get("intro"), + "source": "kg", + } + for item in raw_data + ] + + +def _create_task_body(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]: + base = { + "area_code": "1", + "show_privilege": 1, + "show_album_info": "1", + "is_publish": "", + "appid": 1005, + "clientver": 11451, + "mid": "1", + "dfid": "-", + "clienttime": int(time.time() * 1000), + "key": "OIlwieks28dk2k092lksi2UIkp", + "fields": "album_info,author_name,audio_info,ori_audio_name,base,songname", + } + tasks: list[dict[str, Any]] = [] + rest = list(hashs) + while rest: + tasks.append({"data": rest[:100], **base}) + if len(rest) < 100: + break + rest = rest[100:] + return tasks + + +async def _create_task(hashs: list[dict[str, Any]]) -> list[list[Any]]: + url = "http://gateway.kugou.com/v2/album_audio/audio" + headers = { + "KG-THash": "13a3164", + "KG-RC": "1", + "KG-Fake": "0", + "KG-RF": "00869891", + "User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi", + "x-router": "kmr.service.kugou.com", + } + + async def one(task: dict[str, Any]) -> list[Any]: + data = await create_http_fetch(url, {"method": "POST", "body": task, "headers": headers}) + items = data if isinstance(data, list) else [] + return [entry[0] if entry else None for entry in items] + + return await asyncio.gather(*(one(task) for task in _create_task_body(hashs))) + + +def _de_duplication(datas: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[Any] = set() + result: list[dict[str, Any]] = [] + for item in datas: + hash_value = item.get("hash") + if hash_value in seen: + continue + seen.add(hash_value) + result.append(item) + return result + + +async def _get_music_infos(items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """hash 列表 → SongInfo(走 v2 album_audio 批量接口,与 musicInfo.ts 的 v3 不同)。""" + deduped = _de_duplication(items) + if not deduped: + return [] + chunks = await _create_task([{"hash": item.get("hash")} for item in deduped]) + return _filter_data2([entry for chunk in chunks for entry in chunk]) + + +def _filter_data2(raw_list: list[Any]) -> list[dict[str, Any]]: + ids: set[Any] = set() + result: list[dict[str, Any]] = [] + + for item in raw_list: + if not item: + continue + audio = item.get("audio_info") or {} + if audio.get("audio_id") in ids: + continue + ids.add(audio.get("audio_id")) + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for quality, size_key, hash_key in ( + ("128k", "filesize", "hash"), + ("320k", "filesize_320", "hash_320"), + ("flac", "filesize_flac", "hash_flac"), + ("flac24bit", "filesize_high", "hash_high"), + ): + if str(audio.get(size_key, "0")) == "0": + continue + size = size_formate(int(audio.get(size_key) or 0)) + types.append({"type": quality, "size": size, "hash": audio.get(hash_key)}) + _types[quality] = {"size": size, "hash": audio.get(hash_key)} + album = item.get("album_info") or {} + result.append( + { + "singer": decode_name(item.get("author_name")), + "name": decode_name(item.get("songname")), + "albumName": decode_name(album.get("album_name")), + "albumId": album.get("album_id"), + "songmid": audio.get("audio_id"), + "source": "kg", + "interval": format_play_time(int(audio.get("timelength") or 0) / 1000), + "img": None, + "lrc": None, + "hash": audio.get("hash"), + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return result + + +async def get_list_detail_by_special_id(id: Any, page: int | None = None, try_num: int = 0) -> dict[str, Any]: + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(get_song_list_detail_url(id)) + html = resp.body if isinstance(resp.body, str) else "" + list_data = _LIST_DATA_EXP.search(html) + list_info = _LIST_INFO_EXP.search(html) + if not list_data: + return await get_list_detail_by_special_id(id, page, try_num + 1) + songs = await _get_music_infos(json.loads(list_data.group(1))) + name = list_info.group(1) if list_info else None + pic = list_info.group(2) if list_info else None + return { + "list": songs, + "page": 1, + "limit": LIST_DETAIL_LIMIT, + "total": len(songs), + "source": "kg", + "info": {"name": name, "img": pic, "desc": parse_html_desc(html)}, + } + + +def get_info_url(tag_id: Any = None) -> str: + if tag_id: + return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c={tag_id}" + return "http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&" + + +def get_song_list_url(sort_id: Any, tag_id: Any, page: int) -> str: + tag = "" if tag_id is None else tag_id + return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t={sort_id}&c={tag}&p={page}" + + +def get_song_list_detail_url(id: Any) -> str: + return f"http://www2.kugou.kugou.com/yueku/v9/special/single/{id}-5-9999.html" + + +async def _get_song_list(sort_id: Any, tag_id: Any, page: int, try_num: int = 0) -> list[dict[str, Any]]: + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(get_song_list_url(sort_id, tag_id, page)) + body = resp.body if isinstance(resp.body, dict) else {} + if not body or body.get("status") != 1: + return await _get_song_list(sort_id, tag_id, page, try_num + 1) + return _filter_list(body.get("special_db") or []) + + +async def _get_song_list_recommend(try_num: int = 0) -> list[dict[str, Any]]: + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch( + "http://everydayrec.service.kugou.com/guess_special_recommend", + method="post", + headers={"User-Agent": "KuGou2012-8275-web_browser_event_handler"}, + body={ + "appid": 1001, + "clienttime": 1566798337219, + "clientver": 8275, + "key": "f1f93580115bb106680d2375f8032d96", + "mid": "21511157a05844bd085308bc76ef3343", + "platform": "pc", + "userid": "262643156", + "return_min": 6, + "return_max": 15, + }, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("status") != 1: + return await _get_song_list_recommend(try_num + 1) + return _filter_list((body.get("data") or {}).get("special_list") or []) + + +async def _get_list_info(tag_id: Any, try_num: int = 0) -> dict[str, Any]: + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(get_info_url(tag_id)) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("status") != 1: + return await _get_list_info(tag_id, try_num + 1) + params = (body.get("data") or {}).get("params") or {} + return { + "limit": params.get("pagesize"), + "page": params.get("p"), + "total": params.get("total"), + "source": "kg", + } + + +async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]: + """歌单广场:首屏(无标签、第 1 页、默认排序)额外混入推荐歌单。""" + tasks: list[Any] = [_get_song_list(sort_id, tag_id, page)] + if _CURRENT_TAG_INFO["id"] == tag_id: + tasks.append(asyncio.sleep(0, result=_CURRENT_TAG_INFO["info"])) + else: + + async def load_info() -> dict[str, Any]: + info = await _get_list_info(tag_id) + _CURRENT_TAG_INFO["id"] = tag_id + _CURRENT_TAG_INFO["info"] = dict(info) + return info + + tasks.append(load_info()) + if not tag_id and page == 1 and sort_id == SORT_LIST[0]["id"]: + tasks.append(_get_song_list_recommend()) + + results = await asyncio.gather(*tasks) + songs: list[dict[str, Any]] = results[0] + info: dict[str, Any] = results[1] + if len(results) > 2 and results[2]: + songs = [*results[2], *songs] + return {"list": songs, **info} + + +async def get_tags(try_num: int = 0) -> dict[str, Any]: + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(get_info_url()) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("status") != 1: + return await get_tags(try_num + 1) + data = body.get("data") or {} + return { + "hotTag": _filter_info_hot_tag(data.get("hotTag") or {}), + "tags": _filter_tag_info(data.get("tagids") or {}), + "source": "kg", + } + + +def get_detail_page_url(id: Any) -> str: + value = id + if isinstance(value, str): + if re.search(r"^https?://", value): + return value + value = value.replace("id_", "") + return f"https://www.kugou.com/yy/special/single/{value}.html" + + +async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + from app.sdk.platforms.kg.util import encode_uri_component + + url = ( + f"http://msearchretry.kugou.com/api/v3/search/special?keyword={encode_uri_component(keyword)}" + f"&page={page}&pagesize={limit}&showtype=10&filter=0&version=7910&sver=2" + ) + resp = await http_fetch(url) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("errcode") != 0: + raise RuntimeError("filed") + data = body.get("data") or {} + return { + "list": [ + { + "play_count": format_play_count(item.get("playcount")), + "id": "id_" + str(item.get("specialid")), + "author": item.get("nickname"), + "name": item.get("specialname"), + "time": date_format(item.get("publishtime"), "Y-M-D"), + "img": item.get("imgurl"), + "grade": item.get("grade"), + "desc": item.get("intro"), + "total": item.get("songcount"), + "source": "kg", + } + for item in data.get("info") or [] + ], + "limit": limit, + "total": data.get("total"), + "page": page, + "source": "kg", + } + + +# ---- 歌单详情解析链 ---- + + +async def _decode_gcid(gcid: str) -> str | None: + params = "dfid=-&appid=1005&mid=0&clientver=20109&clienttime=640612895&uuid=-" + body = {"ret_info": 1, "data": [{"id": gcid, "id_type": 2}]} + result = await create_http_fetch( + f"https://t.kugou.com/v1/songlist/batch_decode?{params}" + f"&signature={signature_params(params, 'android', json.dumps(body, separators=(',', ':')))}", + { + "method": "POST", + "headers": { + "User-Agent": ( + "Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36" + ), + "Referer": "https://m.kugou.com/", + }, + "body": body, + }, + ) + items = (result or {}).get("list") or [] + return items[0].get("global_collection_id") if items else None + + +async def _get_user_list_detail_2(global_collection_id: str) -> dict[str, Any]: + if len(global_collection_id) > 1000: + raise RuntimeError("get list error") + params = ( + "appid=1058&specialid=0&global_specialid=" + + global_collection_id + + "&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519" + "&mid=1586163242519&uuid=1586163242519&dfid=-" + ) + headers = { + "mid": "1586163242519", + "Referer": "https://m3ws.kugou.com/share/index.php", + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 " + "(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1" + ), + "dfid": "-", + "clienttime": "1586163242519", + } + info = await create_http_fetch( + f"https://mobiles.kugou.com/api/v5/special/info_v2?{params}&signature={signature_params(params, 'web')}", + {"headers": headers}, + ) + info = info or {} + total = info.get("songcount") or 0 + tasks: list[Any] = [] + page = 0 + while total: + limit = 300 if total > 300 else total + total -= limit + page += 1 + song_params = ( + f"appid=1058&global_specialid={global_collection_id}&specialid=0&plat=0&version=8000" + f"&page={page}&pagesize={limit}&srcappid=2919&clientver=20000" + "&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-" + ) + tasks.append( + create_http_fetch( + f"https://mobiles.kugou.com/api/v5/special/song_v2?{song_params}" + f"&signature={signature_params(song_params, 'web')}", + { + "headers": { + "mid": "1586163263991", + "Referer": "https://m3ws.kugou.com/share/index.php", + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 " + "(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1" + ), + "dfid": "-", + "clienttime": "1586163263991", + } + }, + ) + ) + song_rows: list[Any] = [] + for chunk in await asyncio.gather(*tasks): + song_rows.extend((chunk or {}).get("info") or []) + songs = await _get_music_infos(song_rows) + img = info.get("imgurl") + return { + "list": songs, + "page": 1, + "limit": LIST_DETAIL_LIMIT, + "total": len(songs), + "source": "kg", + "info": { + "name": info.get("specialname"), + "img": img.replace("{size}", "240") if img else None, + "desc": info.get("intro"), + "author": info.get("nickname"), + "play_count": format_play_count(info.get("playcount")), + }, + } + + +async def _get_list_info_by_chain(chain: str) -> Any: + if chain in _CACHE: + return _CACHE[chain] + resp = await http_fetch( + f"https://m.kugou.com/share/?chain={chain}&id={chain}", + headers={ + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 " + "(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1" + ) + }, + ) + html = resp.body if isinstance(resp.body, str) else "" + match = re.search(r"var\sphpParam\s=\s({.+?});", html) + result = json.loads(match.group(1)) if match else None + _CACHE[chain] = result + return result + + +async def _get_user_list_detail_by_pc_chain(chain: str) -> list[dict[str, Any]]: + resp = await http_fetch( + f"http://www.kugou.com/share/{chain}.html", + headers={ + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36" + ) + }, + ) + html = resp.body if isinstance(resp.body, str) else "" + match = re.search(r"var\sdataFromSmarty\s=\s(\[.+?\])", html) + result = json.loads(match.group(1)) if match else [] + return await _get_music_infos(result) + + +async def _get_user_list_detail_by_id(id: Any, page: int, limit: int) -> list[dict[str, Any]]: + signature = _handle_signature(id, page, limit) + info = await create_http_fetch( + "https://pubsongscdn.kugou.com/v2/get_other_list_file?" + f"srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist" + f"&page={page}&pagesize={limit}&specialid={id}&signature={signature}", + { + "headers": { + "Referer": "https://m3ws.kugou.com/share/index.php", + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 " + "(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1" + ), + "dfid": "-", + } + }, + ) + return await _get_music_infos((info or {}).get("info") or []) + + +async def _get_user_list_detail_4(song_info: dict[str, Any], chain: str, page: int) -> dict[str, Any]: + limit = 100 + list_info, songs = await asyncio.gather( + _get_list_info_by_chain(chain), + _get_user_list_detail_by_id(song_info.get("id"), page, limit), + ) + list_info = list_info or {} + img = list_info.get("imgurl") + return { + "list": songs or [], + "page": page, + "limit": limit, + "total": len(songs or []), + "source": "kg", + "info": { + "name": list_info.get("specialname"), + "img": img.replace("{size}", "240") if img else None, + "author": list_info.get("nickname"), + }, + } + + +async def _get_user_list_detail_5(chain: str) -> dict[str, Any]: + list_info, songs = await asyncio.gather(_get_list_info_by_chain(chain), _get_user_list_detail_by_pc_chain(chain)) + list_info = list_info or {} + img = list_info.get("imgurl") + return { + "list": songs or [], + "page": 1, + "limit": LIST_DETAIL_LIMIT, + "total": len(songs or []), + "source": "kg", + "info": { + "name": list_info.get("specialname"), + "img": img.replace("{size}", "240") if img else None, + "author": list_info.get("nickname"), + }, + } + + +async def _get_user_list_detail_3(chain: str, page: int) -> dict[str, Any]: + song_info = await create_http_fetch( + f"http://m.kugou.com/schain/transfer?pagesize={LIST_DETAIL_LIMIT}&chain={chain}" + f"&su=1&page={page}&n=0.7928855356604456", + { + "headers": { + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 " + "(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" + ) + } + }, + ) + song_info = song_info or {} + if not song_info.get("list"): + if song_info.get("global_collection_id"): + return await _get_user_list_detail_2(song_info["global_collection_id"]) + try: + return await _get_user_list_detail_4(song_info, chain, page) + except Exception: # noqa: BLE001 - 降级到 PC 分享页 + return await _get_user_list_detail_5(chain) + songs = await _get_music_infos(song_info.get("list") or []) + info = song_info.get("info") or {} + return { + "list": songs, + "page": 1, + "limit": LIST_DETAIL_LIMIT, + "total": len(songs), + "source": "kg", + "info": {"name": info.get("name"), "img": info.get("img"), "author": info.get("username")}, + } + + +async def _get_user_list_detail_by_code(id: str) -> dict[str, Any]: + song_info = await create_http_fetch( + "http://t.kugou.com/command/", + { + "method": "POST", + "headers": { + "KG-RC": 1, + "KG-THash": "network_super_call.cpp:3676261689:379", + "User-Agent": "", + }, + "body": { + "appid": 1001, + "clientver": 9020, + "mid": "21511157a05844bd085308bc76ef3343", + "clienttime": 640612895, + "key": "36164c4015e704673c588ee202b9ecb8", + "data": id, + }, + }, + ) + song_info = song_info or {} + info = song_info.get("info") or {} + # type 1 单曲 / 2 歌单 / 3 电台 / 4 酷狗码 / 5 别人的播放队列 + if info.get("type") == 2 and not info.get("global_collection_id"): + return await get_list_detail_by_special_id(info.get("id")) + if info.get("global_collection_id"): + return await _get_user_list_detail_2(info["global_collection_id"]) + song_list: Any = None + if info.get("userid") is not None: + song_list = await create_http_fetch( + "http://www2.kugou.kugou.com/apps/kucodeAndShare/app/", + { + "method": "POST", + "headers": { + "KG-RC": 1, + "KG-THash": "network_super_call.cpp:3676261689:379", + "User-Agent": "", + }, + "body": { + "appid": 1001, + "clientver": 9020, + "mid": "21511157a05844bd085308bc76ef3343", + "clienttime": 640612895, + "key": "36164c4015e704673c588ee202b9ecb8", + "data": { + "id": info.get("id"), + "type": 3, + "userid": info.get("userid"), + "collect_type": 0, + "page": 1, + "pagesize": info.get("count"), + }, + }, + }, + ) + rows = song_list or song_info.get("list") or [] + songs = await _get_music_infos(rows) + img_size = info.get("img_size") + return { + "list": songs, + "page": 1, + "limit": info.get("count"), + "total": len(songs), + "source": "kg", + "info": { + "name": info.get("name"), + "img": img_size.replace("{size}", "240") if img_size else info.get("img"), + "author": info.get("username"), + }, + } + + +async def _get_user_list_detail_by_link(body: dict[str, Any], link: str) -> dict[str, Any]: + list_info = (body.get("info") or {}).get("0") or {} + total = list_info.get("count") or 0 + tasks: list[Any] = [] + page = 0 + while total: + limit = 90 if total > 90 else total + total -= limit + page += 1 + url = re.sub(r"pagesize=\d+", f"pagesize={limit}", link) + url = re.sub(r"page=\d+", f"page={page}", url) + tasks.append( + create_http_fetch( + url, + { + "headers": { + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 " + "(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" + ), + "Referer": link, + } + }, + ) + ) + rows: list[Any] = [] + for chunk in await asyncio.gather(*tasks): + rows.extend(((chunk or {}).get("list") or {}).get("info") or []) + songs = await _get_music_infos(rows) + pic = list_info.get("pic") + return { + "list": songs, + "page": page, + "limit": LIST_DETAIL_LIMIT, + "total": len(songs), + "source": "kg", + "info": { + "name": list_info.get("name"), + "img": pic.replace("{size}", "240") if pic else None, + "author": list_info.get("list_create_username"), + }, + } + + +async def _get_user_list_detail(link: str, page: int, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 3: + raise RuntimeError("link try max num") + if "#" in link: + link = re.sub(r"#.*$", "", link) + if "global_collection_id" in link: + return await _get_user_list_detail_2(re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", link)) + if "gcid_" in link: + match = re.search(r"gcid_\w+", link) + if match: + gcid_id = await _decode_gcid(match.group(0)) + if gcid_id: + return await _get_user_list_detail_2(gcid_id) + if "chain=" in link: + return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", link), page) + if ".html" in link: + if "zlist.html" in link: + link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", link) + if "pagesize" in link: + link = link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace("page=1", f"page={page}") + else: + link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}" + elif "song.html" not in link: + return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", link), page) + + resp = await http_fetch( + link, + headers={ + "User-Agent": ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 " + "(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" + ), + "Referer": link, + }, + ) + location = resp.header("location") or None + if resp.status_code > 400: + return await _get_user_list_detail(link, page, retry_num + 1) + if location: + if "global_collection_id" in location: + return await _get_user_list_detail_2( + re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", location) + ) + if "gcid_" in location: + match = re.search(r"gcid_\w+", link) + if match: + gcid_id = await _decode_gcid(match.group(0)) + if gcid_id: + return await _get_user_list_detail_2(gcid_id) + if "chain=" in location: + return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", location), page) + if ".html" in location: + if "zlist.html" in location: + next_link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", location) + if "pagesize" in next_link: + next_link = next_link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace( + "page=1", f"page={page}" + ) + else: + next_link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}" + return await _get_user_list_detail(next_link, page, retry_num + 1) + return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", location), page) + return await _get_user_list_detail(location, page, retry_num + 1) + + if isinstance(resp.body, str): + body_text = resp.body + global_id = re.search(r'"global_collection_id":"(\w+)"', body_text) + gcid_id = global_id.group(1) if global_id else None + if not gcid_id: + match = re.search(r'"encode_gic":"(\w+)"', body_text) or re.search(r'"encode_src_gid":"(\w+)"', body_text) + if match: + gcid_id = await _decode_gcid(match.group(1)) + if not gcid_id: + raise RuntimeError("get list error") + return await _get_user_list_detail_2(gcid_id) + + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("errcode") != 0: + return await _get_user_list_detail(link, page, retry_num + 1) + return await _get_user_list_detail_by_link(body, link) + + +async def get_list_detail(id: Any, page: int | None = None) -> dict[str, Any] | None: + """歌单详情入口:按 id 形态分派到特殊页 / 链接 / 酷狗码。""" + value = str(id) + page_no = page or 1 + if "special/single/" in value: + value = _LIST_DETAIL_LINK_EXP.sub(r"\1", value) + elif re.search(r"https?:", value): + # 修复形如 https://www.kugou.com/songlist/xxx/?uid=xxx&cover=http%3A%2F%2F... 的脏链接 + return await _get_user_list_detail(re.sub(r"^.*?http", "http", value), page_no) + elif re.fullmatch(r"\d+", value): + return await _get_user_list_detail_by_code(value) + elif value.startswith("id_"): + value = value.replace("id_", "") + return await get_list_detail_by_special_id(value, page_no) diff --git a/app/sdk/platforms/kg/util.py b/app/sdk/platforms/kg/util.py new file mode 100644 index 0000000..62b4fec --- /dev/null +++ b/app/sdk/platforms/kg/util.py @@ -0,0 +1,107 @@ +"""酷狗通用请求封装与签名(对应 sdk/kg/util.ts + sdk/auth/kg.ts 的设备/签名部分)。 + +`app/sdk/auth/kg.py` 仍是登录流程的 stub(禁止修改),因此设备指纹与 android/web +签名在这里单独实现:取流(apis/kg.py)与取流兜底都要用同一套指纹,放这里避免重复。 +""" + +from __future__ import annotations + +import hashlib +import uuid +from typing import Any +from urllib.parse import quote + +from app.sdk.auth_store import get_credentials, set_credentials +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import to_md5 + +ANDROID_SALT = "OIlwieks28dk2k092lksi2UIkp" +WEB_SALT = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt" + +_RANDOM_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789" + + +def signature_params(params: str, platform: str = "android", body: str = "") -> str: + """按 key 排序拼接后以盐包裹取 MD5(android 与 web 只是盐不同)。""" + key = WEB_SALT if platform == "web" else ANDROID_SALT + items = params.split("&") + items.sort() + return to_md5(f"{key}{''.join(items)}{body}{key}") + + +def android_sign(params: dict[str, Any], data: str = "") -> str: + """网关签名:`${ANDROID_SALT}排序后的k=v拼接${data}${ANDROID_SALT}` 的 MD5。""" + import json + + parts = [] + for key in sorted(params): + value = params[key] + # JSON.stringify 是紧凑格式(无空格),登录/注册签名依赖逐字节一致 + encoded = ( + json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, (dict, list)) else value + ) + parts.append(f"{key}={encoded}") + return to_md5(f"{ANDROID_SALT}{''.join(parts)}{data}{ANDROID_SALT}") + + +def encode_uri_component(value: Any) -> str: + """对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。""" + return quote(str(value), safe="!~*'()") + + +def random_string(n: int) -> str: + import random + + return "".join(_RANDOM_CHARS[random.randrange(len(_RANDOM_CHARS))] for _ in range(n)) + + +def pkcs7_pad(data: bytes, block: int = 16) -> bytes: + """PKCS#7 填充 —— Node 的 createCipheriv 会自动补,cryptography 不会。""" + remain = block - (len(data) % block) + return data + bytes([remain]) * remain + + +def pkcs7_unpad(data: bytes, block: int = 16) -> bytes: + """PKCS#7 去填充(对应 Node createDecipheriv 的自动 unpad)。""" + if not data: + return data + remain = data[-1] + if remain < 1 or remain > block or len(data) < remain: + return data + return data[:-remain] + + +def get_device() -> dict[str, Any]: + """设备指纹持久化:guid/mid/dfid 稳定复用(取流与登录共用同一指纹)。""" + dev = get_credentials("_kg_device") + if not dev: + dev = {"guid": str(uuid.uuid4()), "mid": "", "dfid": random_string(24)} + if not dev.get("mid"): + dev["mid"] = str(int(hashlib.md5(str(dev.get("guid") or "").encode()).hexdigest(), 16)) + set_credentials("_kg_device", dev) + if not dev.get("dfid"): + dev["dfid"] = random_string(24) + return dev + + +async def create_http_fetch(url: str, options: dict[str, Any] | None = None, retry_num: int = 0) -> Any: + """带重试的请求:非 200 或 errcode 非 0 一律重试(最多 3 次)。 + + 成功时按原实现的取值优先级返回:body.data → body.info(数组时返回整 body) → body.info。 + """ + if retry_num > 2: + raise RuntimeError("try max num") + try: + result = await http_fetch(url, **(options or {})) + except Exception as err: # noqa: BLE001 - 可降级失败:换一次请求重试 + print(f"[kg] 请求失败将重试({retry_num + 1}/3) {url}: {err}") + return await create_http_fetch(url, options, retry_num + 1) + body = result.body if isinstance(result.body, dict) else {} + err_code = body.get("error_code", body.get("errcode", body.get("err_code"))) + if result.status_code != 200 or err_code != 0: + return await create_http_fetch(url, options, retry_num + 1) + if body.get("data"): + return body["data"] + if isinstance(body.get("info"), list): + return body + return body.get("info") diff --git a/app/sdk/platforms/kw/__init__.py b/app/sdk/platforms/kw/__init__.py new file mode 100644 index 0000000..8534b67 --- /dev/null +++ b/app/sdk/platforms/kw/__init__.py @@ -0,0 +1,95 @@ +"""酷我音乐(对应 sdk/kw/index.ts 的 default export)。 + +能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 专辑 / 歌单 / 解析, +顶层挂 get_music_url(取流在 apis/kw.py)、get_lyric、get_pic、get_music_info、 +handle_music_info、get_music_detail_page_url。 + +酷我没有歌手主页子模块(TS 侧也没有),`singer` 能力位留空,路由据此返回 404。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.apis.kw import get_music_url +from app.sdk.common.request import http_fetch +from app.sdk.platforms._base import Platform +from app.sdk.platforms.kw import ( + album, + comment, + hot_search, + leaderboard, + lyric, + music_search, + parse, + pic, + playlist, + song_list, +) +from app.sdk.platforms.kw.util import format_singer + +MUSIC_INFO_API = "http://www.kuwo.cn/api/www/music/musicInfo?mid={songmid}" + + +async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]: + """歌曲详情(歌名/歌手/封面/专辑)。上游 code != 200 时抛出 msg 原文。""" + songmid = str(song_info.get("songmid") or "") + resp = await http_fetch(MUSIC_INFO_API.format(songmid=songmid)) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") == 200: + return body.get("data") or {} + raise RuntimeError(str(body.get("msg") or "获取歌曲信息失败")) + + +async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]: + """用 get_music_info 就地补全 name / singer / img / albumName,与原实现一致。""" + info = await get_music_info(song_info) + song_info["name"] = info.get("name") + song_info["singer"] = format_singer(str(info.get("artist") or "")) + song_info["img"] = info.get("pic") + song_info["albumName"] = info.get("album") + return song_info + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + return f"http://www.kuwo.cn/play_detail/{song_info.get('songmid') or ''}" + + +async def get_music_urls(music_info: dict[str, Any]) -> dict[str, str]: + """批量取各档直链,返回 `{type: url}`(原 getMusicUrls 的回调语义)。""" + import asyncio + + types = music_info.get("types") or [] + songmid = str(music_info.get("songmid") or "") + results = await asyncio.gather( + *(get_music_url({"songmid": songmid}, str(item.get("type") or "")) for item in types), + return_exceptions=True, + ) + type_url: dict[str, str] = {} + for res in results: + if isinstance(res, BaseException): + continue + if res.get("url"): + type_url[str(res.get("type"))] = str(res["url"]) + return type_url + + +MODULE = Platform( + id="kw", + name="酷我音乐", + music_search=music_search, + leaderboard=leaderboard, + song_list=song_list, + hot_search=hot_search, + comment=comment, + album=album, + playlist=playlist, + parse=parse, + get_music_url=get_music_url, + get_music_urls=get_music_urls, + get_lyric=lyric.get_lyric, + get_pic=pic.get_pic, + get_music_info=get_music_info, + handle_music_info=handle_music_info, + get_music_detail_page_url=get_music_detail_page_url, +) diff --git a/app/sdk/platforms/kw/album.py b/app/sdk/platforms/kw/album.py new file mode 100644 index 0000000..874c9a8 --- /dev/null +++ b/app/sdk/platforms/kw/album.py @@ -0,0 +1,251 @@ +"""酷我专辑(对应 sdk/kw/album.ts)。 + +上游 `search.kuwo.cn/r.s?stype=albuminfo` 返回伪 JSON(单引号),服务端固定 +1000 条/页。对外两个入口: + * `get_album_list_detail(id, page)` —— 单页直取,第三参数是 retry_num + * `get_album_detail(id, page, limit)` —— 统一形状,limit 小于 1000 时本地开窗 +路由按 hasattr 分流(`app/routes/music.py:584`),两个名字都要提供。 +""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import decode_name +from app.sdk.platforms.kw.util import format_singer, obj_str2json + +LIST_LIMIT = 36 +SONG_LIMIT = 1000 +# 本地开窗最多翻到第 50 个服务端页,避免异常 total 导致长循环 +MAX_SERVER_PAGES = 50 + +DETAIL_API = ( + "http://search.kuwo.cn/r.s?pn={pn}&rn={rn}&stype=albuminfo&albumid={id}" + "&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0" +) +SEARCH_API = ( + "http://search.kuwo.cn/r.s?all={keyword}&ft=album&itemset=web_2013&client=kt" + "&pcmp4=1&geo=c&vipver=1&pn={pn}&rn={rn}&rformat=json&encoding=utf8" +) + +# `formats` 字段是 `|` 分隔的音质标记;这里只认对外暴露的四个档位 +_FORMAT_QUALITY = ( + ("MP3128", "128k"), + ("MP3H", "320k"), + ("ALFLAC", "flac"), + ("HIRFLAC", "flac24bit"), +) + + +def _to_int(value: Any) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return 0 + + +def _first_non_empty(*values: Any) -> str: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + +def _filter_list_detail(raw_list: list[dict[str, Any]], album_name: str, album_id: Any) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in raw_list: + formats = str(item.get("formats") or "").split("|") + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for marker, quality in _FORMAT_QUALITY: + if marker not in formats: + continue + types.append({"type": quality, "size": None}) + _types[quality] = {"size": None} + result.append( + { + "singer": format_singer(decode_name(item.get("artist"))), + "name": decode_name(item.get("name")), + "albumName": album_name, + "albumId": album_id, + "songmid": item.get("id"), + "source": "kw", + # 专辑接口不返回时长/封面,宁可为空也不猜 + "interval": None, + "img": item.get("pic"), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return result + + +async def get_album_list_detail(album_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + url = DETAIL_API.format(pn=page - 1, rn=SONG_LIMIT, id=album_id) + resp = await http_fetch(url) + if resp.status_code != 200: + return await get_album_list_detail(album_id, page, retry_num + 1) + body = obj_str2json(resp.raw.decode("utf-8", errors="replace")) + if not body.get("musiclist"): + return await get_album_list_detail(album_id, page, retry_num + 1) + + album_name = decode_name(body.get("name")) + return { + "list": _filter_list_detail(body["musiclist"], album_name, body.get("albumid")), + "page": page, + "limit": SONG_LIMIT, + "total": _to_int(body.get("songnum")), + "source": "kw", + "info": { + "name": album_name, + "img": body.get("img") or body.get("hts_img"), + "desc": decode_name(body.get("info")), + "author": decode_name(body.get("artist")), + }, + } + + +async def get_album_detail(album_id: str, page: int = 1, limit: int = 1000) -> dict[str, Any]: + """统一形状的专辑详情。limit >= 1000 时服务端页大小够用,直通单页接口。""" + album_key = str(album_id or "").strip() + if not album_key: + raise RuntimeError("酷我专辑 id 为空") + page_no = max(1, _to_int(page) or 1) + page_size = max(1, _to_int(limit) or SONG_LIMIT) + if page_size >= SONG_LIMIT: + return await get_album_list_detail(album_key, page_no) + + start = (page_no - 1) * page_size + server_page = start // SONG_LIMIT + 1 + skip = start % SONG_LIMIT + songs: list[dict[str, Any]] = [] + info: dict[str, Any] | None = None + total = 0 + while len(songs) < page_size and server_page <= MAX_SERVER_PAGES: + res = await get_album_list_detail(album_key, server_page) + if not res: + break + if info is None: + info = res.get("info") + total = _to_int(res.get("total")) or total + raw_list = res.get("list") or [] + if not raw_list: + break + songs.extend(raw_list[skip : skip + (page_size - len(songs))]) + skip = 0 + if len(raw_list) < SONG_LIMIT: + break + if total > 0 and len(songs) >= total: + break + server_page += 1 + + return { + "source": "kw", + "info": info or {"name": album_key, "img": "", "desc": "", "author": ""}, + "list": songs, + "page": page_no, + "limit": page_size, + "total": total or len(songs), + } + + +async def get_album_songs(album_id: str) -> list[dict[str, Any]]: + """聚合全部分页曲目并按 songmid 去重。""" + album_key = str(album_id or "").strip() + if not album_key: + raise RuntimeError("酷我专辑 id 为空") + songs: list[dict[str, Any]] = [] + seen: set[str] = set() + for page in range(1, MAX_SERVER_PAGES + 1): + res = await get_album_list_detail(album_key, page) + raw_list = res.get("list") or [] + for song in raw_list: + key = str(song.get("songmid") or "") + if key and key in seen: + continue + if key: + seen.add(key) + songs.append(song) + total = _to_int(res.get("total")) + if len(raw_list) < SONG_LIMIT: + break + if total > 0 and len(songs) >= total: + break + return songs + + +async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]: + word = str(keyword or "").strip() + if not word: + return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0} + page_no = max(1, _to_int(page) or 1) + page_size = max(1, _to_int(limit) or 10) + + resp = await http_fetch(SEARCH_API.format(keyword=quote(word, safe=""), pn=page_no - 1, rn=page_size)) + if resp.status_code != 200: + raise RuntimeError("酷我专辑搜索失败") + text = resp.raw.decode("utf-8", errors="replace") + try: + data = obj_str2json(text) + except Exception as err: + raise RuntimeError("酷我专辑搜索失败") from err + if not data.get("albumlist"): + raise RuntimeError("酷我专辑搜索失败") + + result: list[dict[str, Any]] = [] + for item in data["albumlist"]: + album_key = _first_non_empty(item.get("albumid"), item.get("id")) + if not album_key: + continue + result.append( + { + "id": album_key, + "name": decode_name(_first_non_empty(item.get("name"))), + "cover": _first_non_empty(item.get("hts_img"), item.get("img")), + "trackCount": _to_int(item.get("musiccnt")), + "playCount": _to_int(item.get("PLAYCNT") or item.get("playcnt")), + "creator": decode_name(_first_non_empty(item.get("aartist"), item.get("artist"))), + "description": decode_name(_first_non_empty(item.get("info"))), + "link": f"http://www.kuwo.cn/album_detail/{album_key}", + "source": "kw", + "type": "album", + "company": decode_name(_first_non_empty(item.get("company"))), + "publishTime": _first_non_empty(item.get("pub")), + } + ) + return { + "source": "kw", + "list": result, + "page": page_no, + "limit": page_size, + "total": _to_int(data.get("TOTAL") or data.get("total")) or len(result), + } + + +async def parse_album_link(link: str) -> dict[str, Any]: + raw = str(link or "") + for pattern in (r"album_detail/(\d+)", r"album/(\d+)", r"albumid=(\d+)"): + match = re.search(pattern, raw) + if match: + return await get_album_detail(match.group(1), 1) + raise RuntimeError("无效的酷我专辑链接") + + +__all__ = [ + "get_album_detail", + "get_album_list_detail", + "get_album_songs", + "parse_album_link", + "search_album", +] diff --git a/app/sdk/platforms/kw/comment.py b/app/sdk/platforms/kw/comment.py new file mode 100644 index 0000000..a34cd3c --- /dev/null +++ b/app/sdk/platforms/kw/comment.py @@ -0,0 +1,89 @@ +"""酷我评论(对应 sdk/kw/comment.ts)。 + +`ncomment.kuwo.cn/com.s` 只认安卓 Dalvik UA;`type=get_comment` 取最新评论、 +`type=get_rec_comment` 取热评,两者响应字段名不同(comments/comments_counts +vs hot_comments/hot_comments_counts)。 +""" + +from __future__ import annotations + +import math +from typing import Any +from urllib.parse import unquote + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import date_format2 + +COMMENT_API = ( + "http://ncomment.kuwo.cn/com.s?f=web&type={type}&aapiver=1&prod=kwplayer_ar_10.5.2.0" + "&digest=15&sid={songmid}&start={start}&msgflag=1&count={count}&newver=3&uid=0" +) + +HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"} + + +def _to_int(value: Any) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return 0 + + +def _reply_comment(item: dict[str, Any]) -> dict[str, Any]: + return { + "id": item.get("id"), + "text": item.get("msg"), + "time": item.get("time"), + "timeStr": date_format2(_to_int(item.get("time")) * 1000), + "userName": item.get("u_name"), + "avatar": item.get("u_pic"), + "userId": item.get("u_id"), + "likedCount": item.get("like_num"), + "images": [item["mpic"]] if item.get("mpic") else [], + } + + +def _filter_comment(raw_list: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + if not raw_list: + return [] + result: list[dict[str, Any]] = [] + for item in raw_list: + data = _reply_comment(item) + data["images"] = [unquote(str(item["mpic"]))] if item.get("mpic") else [] + # 顶层评论多一个楼中楼数组;原实现对父级 mpic 做 decodeURIComponent,子级不做 + data["reply"] = [_reply_comment(child) for child in item.get("child_comments") or []] + result.append(data) + return result + + +async def _get_comment(songmid: str, page: int, limit: int, *, hot: bool) -> dict[str, Any]: + comment_type = "get_rec_comment" if hot else "get_comment" + url = COMMENT_API.format(type=comment_type, songmid=songmid, start=limit * (page - 1), count=limit) + resp = await http_fetch(url, headers=HEADERS) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or str(body.get("code")) != "200": + raise RuntimeError("获取热门评论失败" if hot else "获取评论失败") + + if hot: + total = _to_int(body.get("hot_comments_counts")) + comments = _filter_comment(body.get("hot_comments")) + else: + total = _to_int(body.get("comments_counts")) + comments = _filter_comment(body.get("comments")) + return { + "source": "kw", + "comments": comments, + "total": total, + "page": page, + "limit": limit, + # 原实现用 `Math.ceil(total/limit) || 1` 兜住 0 条 + "maxPage": math.ceil(total / limit) if limit and total else 1, + } + + +async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]: + return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=False) + + +async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]: + return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=True) diff --git a/app/sdk/platforms/kw/hot_search.py b/app/sdk/platforms/kw/hot_search.py new file mode 100644 index 0000000..2ed1a55 --- /dev/null +++ b/app/sdk/platforms/kw/hot_search.py @@ -0,0 +1,28 @@ +"""酷我热搜(对应 sdk/kw/hotSearch.ts)。""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch + +HOT_API = ( + "http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2" + "&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1¬race=0&uid=0" + "&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1" +) + +# 该接口只认移动端 Dalvik UA,桌面 UA 会返回非 JSON +HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"} + + +def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]: + return [str(item.get("key") or "") for item in raw_list] + + +async def get_list() -> dict[str, Any]: + resp = await http_fetch(HOT_API, headers=HEADERS) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("status") != "ok": + raise RuntimeError("获取热搜词失败") + return {"source": "kw", "list": _filter_list(body.get("tagvalue") or [])} diff --git a/app/sdk/platforms/kw/leaderboard.py b/app/sdk/platforms/kw/leaderboard.py new file mode 100644 index 0000000..5dd9fc9 --- /dev/null +++ b/app/sdk/platforms/kw/leaderboard.py @@ -0,0 +1,155 @@ +"""酷我榜单(对应 sdk/kw/leaderboard.ts)。 + +`get_boards` 直接返回硬编码榜单表(不发请求),id 形如 `kw__93`(路由层会剥掉前缀 +再把裸 bangid 传回 `get_list`)。详情走 `wbd.kuwo.cn/api/bd/bang/bang_info`: +请求体整体 AES-128-ECB 加密 + 签名,响应同样是加密体,`util.wbd_*` 负责双向转换。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import decode_name, format_play_time +from app.sdk.platforms.kw.util import format_singer, wbd_build_param, wbd_decode_data + +LIST_DETAIL_LIMIT = 100 + +BOARD_LIST: list[dict[str, str]] = [ + {"id": "kw__93", "name": "飙升榜", "bangid": "93"}, + {"id": "kw__17", "name": "新歌榜", "bangid": "17"}, + {"id": "kw__16", "name": "热歌榜", "bangid": "16"}, + {"id": "kw__158", "name": "抖音热歌榜", "bangid": "158"}, + {"id": "kw__292", "name": "铃声榜", "bangid": "292"}, + {"id": "kw__284", "name": "热评榜", "bangid": "284"}, + {"id": "kw__290", "name": "ACG新歌榜", "bangid": "290"}, + {"id": "kw__286", "name": "台湾KKBOX榜", "bangid": "286"}, + {"id": "kw__279", "name": "冬日暖心榜", "bangid": "279"}, + {"id": "kw__281", "name": "巴士随身听榜", "bangid": "281"}, + {"id": "kw__255", "name": "KTV点唱榜", "bangid": "255"}, + {"id": "kw__280", "name": "家务进行曲榜", "bangid": "280"}, + {"id": "kw__282", "name": "熬夜修仙榜", "bangid": "282"}, + {"id": "kw__283", "name": "枕边轻音乐榜", "bangid": "283"}, + {"id": "kw__278", "name": "古风音乐榜", "bangid": "278"}, + {"id": "kw__264", "name": "Vlog音乐榜", "bangid": "264"}, + {"id": "kw__242", "name": "电音榜", "bangid": "242"}, + {"id": "kw__187", "name": "流行趋势榜", "bangid": "187"}, + {"id": "kw__204", "name": "现场音乐榜", "bangid": "204"}, + {"id": "kw__186", "name": "ACG神曲榜", "bangid": "186"}, + {"id": "kw__185", "name": "最强翻唱榜", "bangid": "185"}, + {"id": "kw__26", "name": "经典怀旧榜", "bangid": "26"}, + {"id": "kw__104", "name": "华语榜", "bangid": "104"}, + {"id": "kw__182", "name": "粤语榜", "bangid": "182"}, + {"id": "kw__22", "name": "欧美榜", "bangid": "22"}, + {"id": "kw__184", "name": "韩语榜", "bangid": "184"}, + {"id": "kw__183", "name": "日语榜", "bangid": "183"}, + {"id": "kw__145", "name": "会员畅听榜", "bangid": "145"}, + {"id": "kw__153", "name": "网红新歌榜", "bangid": "153"}, + {"id": "kw__64", "name": "影视金曲榜", "bangid": "64"}, + {"id": "kw__176", "name": "DJ嗨歌榜", "bangid": "176"}, + {"id": "kw__106", "name": "真声音", "bangid": "106"}, + {"id": "kw__12", "name": "Billboard榜", "bangid": "12"}, + {"id": "kw__49", "name": "iTunes音乐榜", "bangid": "49"}, + {"id": "kw__180", "name": "beatport电音榜", "bangid": "180"}, + {"id": "kw__13", "name": "英国UK榜", "bangid": "13"}, + {"id": "kw__164", "name": "百大DJ榜", "bangid": "164"}, + {"id": "kw__246", "name": "YouTube音乐排行榜", "bangid": "246"}, + {"id": "kw__265", "name": "韩国Genie榜", "bangid": "265"}, + {"id": "kw__14", "name": "韩国M-net榜", "bangid": "14"}, + {"id": "kw__8", "name": "香港电台榜", "bangid": "8"}, + {"id": "kw__15", "name": "日本公信榜", "bangid": "15"}, + {"id": "kw__151", "name": "腾讯音乐人原创榜", "bangid": "151"}, +] + +BANG_API = "https://wbd.kuwo.cn/api/bd/bang/bang_info" + +_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"} +# 榜单列表按音质等级升序排列(128k → 320k → flac → flac24bit) +_QUALITY_RANK = {"flac24bit": 4, "flac": 3, "320k": 2, "128k": 1} + +_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)") + + +def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in raw_list: + types: list[dict[str, str]] = [] + _types: dict[str, dict[str, str]] = {} + seen: set[str] = set() + for entry in str(item.get("n_minfo") or "").split(";"): + match = _MININFO_RE.search(entry) + if match is None: + continue + bitrate = match.group(2) + if bitrate in seen: + continue + seen.add(bitrate) + quality = _BITRATE_QUALITY.get(bitrate) + if quality is None: + continue + # 原实现 types 留原样、_types 转大写,两者不完全一致 + types.append({"type": quality, "size": match.group(4)}) + _types[quality] = {"size": match.group(4).upper()} + types.sort(key=lambda entry: _QUALITY_RANK[entry["type"]]) + + result.append( + { + "singer": format_singer(decode_name(item.get("artist"))), + "name": decode_name(item.get("name")), + "albumName": decode_name(item.get("album")), + "albumId": item.get("albumId"), + "songmid": item.get("id"), + "source": "kw", + "interval": format_play_time(_to_int(item.get("duration"))), + "img": item.get("pic"), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return result + + +def _to_int(value: Any) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return 0 + + +async def get_boards() -> dict[str, Any]: + return {"list": BOARD_LIST, "source": "kw"} + + +async def get_list(board_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + retry_num += 1 + if retry_num > 3: + raise RuntimeError("try max num") + + body = { + "uid": "", + "devId": "", + "sFrom": "kuwo_sdk", + "user_type": "AP", + "carSource": "kwplayercar_ar_6.0.1.0_apk_keluze.apk", + "id": board_id, + "pn": page - 1, + "rn": LIST_DETAIL_LIMIT, + } + resp = await http_fetch(f"{BANG_API}?{wbd_build_param(body)}") + # 原实现直接调 decodeData,解密失败即 reject、不重试,这里保持一致 + raw_data = wbd_decode_data(resp.body) if isinstance(resp.body, str) else {} + data = raw_data.get("data") or {} + if resp.status_code != 200 or raw_data.get("code") != 200 or not data.get("musiclist"): + return await get_list(board_id, page, retry_num) + + return { + "total": _to_int(data.get("total")), + "list": _filter_data(data["musiclist"]), + "limit": LIST_DETAIL_LIMIT, + "page": page, + "source": "kw", + } diff --git a/app/sdk/platforms/kw/lyric.py b/app/sdk/platforms/kw/lyric.py new file mode 100644 index 0000000..6134437 --- /dev/null +++ b/app/sdk/platforms/kw/lyric.py @@ -0,0 +1,124 @@ +"""酷我歌词(对应 sdk/kw/lyric.ts)。 + +`newlyric.kuwo.cn/newlyric.lrc?`:params 是明文字符串与 `yeelion` 逐字节异或 +后的 base64。响应体是 zlib 压缩 + 可选 yeelion 异或,由 `lyric_decode` 还原。 + +歌词分两种形态:普通 lrc(`[mm:ss.xx]文本`)与 lrcx/逐字(每行带 `` 标记)。 +lrcx 走 `util.parse_yrc` 归一到 `yrc` 字段,`lyric`/`tlyric` 里的逐字标记则剥掉, +保持纯 lrc 可直接给播放器。 +""" + +from __future__ import annotations + +import base64 +import json +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import decode_name +from app.sdk.platforms.kw.lyric_decode import handle_kw_decode_lyric +from app.sdk.platforms.kw.util import parse_yrc + +_BUF_KEY = b"yeelion" + +LYRIC_API = "http://newlyric.kuwo.cn/newlyric.lrc" + +_TIME_EXP = re.compile(r"^\[([\d:.]*)\]") +_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]") +_EXIST_TIME_EXP = re.compile(r"\[\d{1,2}:.*\d{1,4}\]") +_LYRICX_TAG = re.compile(r"^<-?\d+,-?\d+>") +_WORD_TIME_ALL = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>") + + +def build_params(songmid: str, is_get_lyricx: bool) -> str: + """明文参数与 `yeelion` 循环异或后 base64。 + + 原实现用 `new Uint16Array` 中转再 `Buffer.from(output)`;Node 对 TypedArray + 走 fromArrayLike,按元素截断成 8 位字节,等价于这里的逐字节异或。 + """ + params = f"user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_{songmid}" + if is_get_lyricx: + params += "&lrcx=1" + raw = params.encode() + return base64.b64encode(bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(raw))).decode() + + +def _sort_lrc_arr(arr: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + """按时间去重:重复时间视为翻译行,挤进 lrc_t。 + + 全部行都没有逐字标记、且「翻译行」占比异常时,认为数据错乱并抛 failed。 + """ + lrc_set: set[str] = set() + lrc: list[dict[str, str]] = [] + lrc_t: list[dict[str, str]] = [] + is_lyricx = False + for item in arr: + if item["time"] in lrc_set: + if len(lrc) < 2: + continue + t_item = lrc.pop() + t_item["time"] = lrc[-1]["time"] + lrc_t.append(t_item) + lrc.append(item) + else: + lrc.append(item) + lrc_set.add(item["time"]) + if not is_lyricx and _LYRICX_TAG.match(item["text"]): + is_lyricx = True + if not is_lyricx and len(lrc_t) > len(lrc) * 0.3 and len(lrc) - len(lrc_t) > 6: + raise RuntimeError("failed") + return lrc, lrc_t + + +def _transform_lrc(tags: list[str], lrc_list: list[dict[str, str]]) -> str: + body = "".join(f"[{item['time']}]{item['text']}\n" for item in lrc_list) + return "\n".join(tags) + "\n" + body + + +def parse_lrc(lrc: str) -> dict[str, str]: + tags: list[str] = [] + arr: list[dict[str, str]] = [] + for raw_line in re.split(r"\r\n|\r|\n", lrc): + line = raw_line.strip() + match = _TIME_EXP.match(line) + if match: + text = _TIME_EXP.sub("", line, count=1).strip() + time = match.group(1) + # 只有两位小数的补一个 0,与三位/更长的保持同宽 + if re.search(r"\.\d\d$", time): + time += "0" + arr.append({"time": time, "text": text}) + elif _TAG_LINE.search(line): + tags.append(line) + + lrc_list, lrc_t = _sort_lrc_arr(arr) + return { + "lyric": decode_name(_transform_lrc(tags, lrc_list)), + "tlyric": decode_name(_transform_lrc(tags, lrc_t)) if lrc_t else "", + } + + +async def get_lyric(song_info: dict[str, Any], is_get_lyricx: bool = True) -> dict[str, str]: + songmid = str(song_info.get("songmid") or "") + resp = await http_fetch(f"{LYRIC_API}?{build_params(songmid, is_get_lyricx)}") + if resp.status_code != 200: + raise RuntimeError(json.dumps(resp.body, ensure_ascii=False)) + + base64_data = await handle_kw_decode_lyric(base64.b64encode(resp.raw).decode(), is_get_lyricx) + try: + lrc_info = parse_lrc(base64.b64decode(base64_data).decode("utf-8", errors="replace")) + except Exception as err: + raise RuntimeError("Get lyric failed") from err + + if lrc_info.get("tlyric"): + lrc_info["tlyric"] = _WORD_TIME_ALL.sub("", lrc_info["tlyric"]) + # yrc 解析失败只代表这份歌词没有逐字数据,不影响普通歌词返回 + try: + lrc_info["yrc"] = parse_yrc(lrc_info["lyric"]) + except Exception: # noqa: BLE001 + lrc_info["yrc"] = "" + lrc_info["lyric"] = _WORD_TIME_ALL.sub("", lrc_info["lyric"]) + if not _EXIST_TIME_EXP.search(lrc_info["lyric"]): + raise RuntimeError("Get lyric failed") + return lrc_info diff --git a/app/sdk/platforms/kw/lyric_decode.py b/app/sdk/platforms/kw/lyric_decode.py new file mode 100644 index 0000000..b125647 --- /dev/null +++ b/app/sdk/platforms/kw/lyric_decode.py @@ -0,0 +1,38 @@ +"""酷我歌词解码(对应 sdk/kw/lyricDecode.ts)。 + +响应体前缀是 `tp=content`,`\\r\\n\\r\\n` 之后为 zlib 压缩数据;非 lrcx(普通歌词) +直接按 gb18030 解码,lrcx(逐字歌词)先 base64 解出、再与 `yeelion` 逐字节异或, +最后按 gb18030 解码。算法来源为酷我桌面端歌词解码实现。 +""" + +from __future__ import annotations + +import base64 +import zlib + +_BUF_KEY = b"yeelion" + + +def _xor_yeelion(data: bytes) -> bytes: + """逐字节与 `yeelion` 循环异或(原实现用双层 while,等价于 i % len(key))。""" + return bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(data)) + + +async def decode_kw_lyric(buf: bytes, is_get_lyricx: bool) -> str: + """解出明文歌词。非酷我歌词格式返回空串(与原实现一致)。""" + if buf[:10].decode("utf-8", errors="replace") != "tp=content": + return "" + # 找不到分隔时原实现 indexOf 返回 -1,+4 后从第 3 字节开始切片,这里保持一致 + lrc_data = zlib.decompress(buf[buf.find(b"\r\n\r\n") + 4 :]) + + if not is_get_lyricx: + return lrc_data.decode("gb18030", errors="replace") + + raw = base64.b64decode(lrc_data.decode("utf-8", errors="replace")) + return _xor_yeelion(raw).decode("gb18030", errors="replace") + + +async def handle_kw_decode_lyric(lrc_base64: str, is_get_lyricx: bool) -> str: + """解码入口:入参与返回值都是 base64(对应原 handleKwDecodeLyric)。""" + lrc = await decode_kw_lyric(base64.b64decode(lrc_base64), is_get_lyricx) + return base64.b64encode(lrc.encode()).decode() diff --git a/app/sdk/platforms/kw/music_search.py b/app/sdk/platforms/kw/music_search.py new file mode 100644 index 0000000..63bfce5 --- /dev/null +++ b/app/sdk/platforms/kw/music_search.py @@ -0,0 +1,116 @@ +"""酷我搜索(对应 sdk/kw/musicSearch.ts)。 + +搜索接口 `search.kuwo.cn/r.s` 返回的是带单引号的 JS 对象字面量,必须走 +`util.obj_str2json` 而不能直接 json.loads。音质清单在 `N_MINFO` 字段里, +`;` 分隔多档、每档形如 `level:ff,bitrate:2000,format:flac,size:32.43Mb`; +`mflac`/`mgg`/`mmp4`/`zp` 等加密码流的 bitrate 是 20000+ 的随机值,不在 +4000/2000/320/128 四档之内,天然被过滤掉。 +""" + +from __future__ import annotations + +import math +import re +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import decode_name, format_play_time +from app.sdk.platforms.kw.util import format_singer, obj_str2json + +DEFAULT_LIMIT = 30 + +SEARCH_API = ( + "http://search.kuwo.cn/r.s?client=kt&all={keyword}&pn={pn}&rn={rn}" + "&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1" + "&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1" +) + +# bitrate 精确值 → 对外音质名。只认这四档,加密档位一律丢弃。 +_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"} + +_MINFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)") + + +def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]] | None: + """`N_MINFO` 缺失时返回 None(原文用 console.log 提示后触发重试)。""" + result: list[dict[str, Any]] = [] + for info in raw_list: + n_minfo = info.get("N_MINFO") + if not n_minfo: + return None + + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for entry in str(n_minfo).split(";"): + match = _MINFO_RE.search(entry) + if match is None: + continue + quality = _BITRATE_QUALITY.get(match.group(2)) + if quality is None: + continue + size = match.group(4) + types.append({"type": quality, "size": size}) + _types[quality] = {"size": size.upper()} + # 原文按 N_MINFO 的书写顺序收集后整体反转(加密档在前,明文档在后) + types.reverse() + + interval = _to_int(info.get("DURATION")) + result.append( + { + "name": decode_name(info.get("SONGNAME")), + "singer": format_singer(decode_name(info.get("ARTIST"))), + "source": "kw", + "songmid": str(info.get("MUSICRID") or "").replace("MUSIC_", ""), + "albumId": decode_name(info.get("ALBUMID") or ""), + "interval": format_play_time(interval), + "albumName": decode_name(info.get("ALBUM")) if info.get("ALBUM") else "", + "lrc": None, + "img": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return result + + +def _to_int(value: Any) -> int: + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return 0 + + +async def search(keyword: str, page: int = 1, limit: int | None = None, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + page_size = DEFAULT_LIMIT if limit is None else limit + url = SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=page_size) + resp = await http_fetch(url) + # 上游偶发返回非 JSON(风控页);原文靠 `!result` 兜住并重试 + result = resp.body + if isinstance(result, str): + # rformat=json 正常回合法 JSON,但风控页/旧版接口会回单引号伪 JSON + try: + result = obj_str2json(result) + except ValueError: + return await search(keyword, page, limit, retry_num + 1) + if not isinstance(result, dict): + return await search(keyword, page, limit, retry_num + 1) + if result.get("TOTAL") != "0" and result.get("SHOW") == "0": + return await search(keyword, page, limit, retry_num + 1) + + songs = _handle_result(result.get("abslist") or []) + if songs is None: + return await search(keyword, page, limit, retry_num + 1) + + total = _to_int(result.get("TOTAL")) + return { + "list": songs, + "allPage": math.ceil(total / page_size) if page_size else 1, + "total": total, + "limit": page_size, + "source": "kw", + } diff --git a/app/sdk/platforms/kw/parse.py b/app/sdk/platforms/kw/parse.py new file mode 100644 index 0000000..1b6948c --- /dev/null +++ b/app/sdk/platforms/kw/parse.py @@ -0,0 +1,117 @@ +"""酷我链接解析(对应 sdk/kw/parse.ts)。 + +歌曲:`www.kuwo.cn/play_detail/`、`m.kuwo.cn/h5app/song/`、 +`musicId=` 或裸 rid(4 位以上数字)。元数据取 +`m.kuwo.cn/newh5/singles/songinfoandlrc`,风控时会回 status 301「音乐查询失败」, +按 600ms×(n+1) 退避重试最多 5 次(约 9 秒)。 + +直链是尽力的:版权/风控导致取流失败时仍返回元数据(与上游 fetchAudioURL 降级一致)。 +""" + +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_time +from app.sdk.platforms.kw import album, playlist +from app.sdk.platforms.kw.util import format_singer + +_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" + +SONG_PATTERNS = ( + re.compile(r"play_detail/(\d+)"), + re.compile(r"h5app/song/(\d+)"), + re.compile(r"musicId=(\d+)"), + re.compile(r"^\d{4,}$"), +) + +META_API = "http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId={rid}&httpsStatus=1" +MAX_META_RETRY = 5 + + +def extract_song_id(link: Any) -> str: + raw = str(link if link is not None else "").strip() + for pattern in SONG_PATTERNS: + match = pattern.search(raw) + if match: + return match.group(1) if match.groups() else raw + return "" + + +def _to_int(value: Any) -> int: + try: + return int(float(str(value).strip())) + except (TypeError, ValueError): + return 0 + + +async def fetch_song_meta(rid: str, retry_num: int = 0) -> dict[str, Any]: + resp = await http_fetch( + META_API.format(rid=rid), + method="get", + headers={"User-Agent": _UA, "Cookie": "wd=1;vip=0"}, + timeout=15_000, + ) + data: Any = resp.body + if isinstance(data, str): + try: + data = json.loads(data) + except ValueError: + data = None + songinfo = ((data or {}).get("data") or {}).get("songinfo") + if not songinfo: + if retry_num < MAX_META_RETRY: + await asyncio.sleep(0.6 * (retry_num + 1)) + return await fetch_song_meta(rid, retry_num + 1) + msg = (data or {}).get("msg") if isinstance(data, dict) else None + raise RuntimeError(msg or "酷我歌曲不存在或已下架") + return songinfo + + +async def parse_song(link: str) -> dict[str, Any]: + rid = extract_song_id(link) + if not rid: + raise RuntimeError("无效的酷我歌曲链接") + + meta = await fetch_song_meta(rid) + duration = _to_int(meta.get("duration")) + album_id = meta.get("albumId") + song: dict[str, Any] = { + "songmid": rid, + "name": meta.get("songName") or f"Kuwo_Song_{rid}", + "singer": format_singer(str(meta.get("artist") or "").strip()), + "albumName": str(meta.get("album") or "").strip(), + "albumId": "" if album_id is None else str(album_id), + "interval": format_play_time(duration) if duration > 0 else "--/--", + "img": meta.get("pic") or None, + "lrc": None, + "otherSource": None, + "types": [], + "_types": {}, + "typeUrl": {}, + "source": "kw", + "extra": {"rid": rid, "link": f"http://www.kuwo.cn/play_detail/{rid}"}, + } + + try: + from app.sdk.apis.kw import get_music_url + + hit = await get_music_url({"songmid": rid}, "128k") + if hit.get("url"): + song["url"] = hit["url"] + song["type"] = hit["type"] + except Exception: # noqa: BLE001 - 版权/风控取流失败时仍返回元数据 + pass + return song + + +async def parse_album(link: str) -> dict[str, Any]: + return await album.parse_album_link(link) + + +async def parse_playlist(link: str) -> dict[str, Any]: + return await playlist.parse_playlist_link(link) diff --git a/app/sdk/platforms/kw/pic.py b/app/sdk/platforms/kw/pic.py new file mode 100644 index 0000000..737306d --- /dev/null +++ b/app/sdk/platforms/kw/pic.py @@ -0,0 +1,22 @@ +"""酷我封面(对应 sdk/kw/pic.ts)。 + +`artistpicserver.kuwo.cn/pic.web` 返回的是一段纯文本(不是 JSON),内容就是图片 URL; +取不到时返回 `None`(原实现用 `/^http/` 判定,不匹配即 null)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch + +PIC_API = "http://artistpicserver.kuwo.cn/pic.web" +PIC_PARAMS = "corp=kuwo&type=rid_pic&pictype=500&size=500" + + +async def get_pic(song_info: dict[str, Any]) -> str | None: + songmid = str(song_info.get("songmid") or "") + resp = await http_fetch(f"{PIC_API}?{PIC_PARAMS}&rid={songmid}") + text = resp.body if isinstance(resp.body, str) else "" + text = text.strip() + return text if text.startswith("http") else None diff --git a/app/sdk/platforms/kw/playlist.py b/app/sdk/platforms/kw/playlist.py new file mode 100644 index 0000000..46f9ff4 --- /dev/null +++ b/app/sdk/platforms/kw/playlist.py @@ -0,0 +1,255 @@ +"""酷我歌单(对应 sdk/kw/playlist.ts)。 + +在 `song_list` 之上做统一形状: + * `get_playlist_detail(id, page, limit)` —— 服务端 1000 条/页,本地按 page/limit 开窗 + * `get_playlist_songs(id)` —— 聚合全部页曲目 + * `search_playlist` / `get_categories` / `get_category_playlists` / `get_recommended` +推荐(getRcmPlayList)服务端 limit 固定 36,显式更小的 limit 在本地截断。 +""" + +from __future__ import annotations + +import math +import re +from typing import Any + +from app.sdk.platforms.kw import song_list + +SERVER_PAGE_SIZE = song_list.SONG_LIMIT +MAX_SERVER_PAGES = 60 + +_ID_PATTERNS = ( + re.compile(r"playlist_detail/(\d+)"), + re.compile(r"/playlist/(\d+)"), + re.compile(r"playlistId=(\d+)"), +) +_DIGEST_PREFIX_RE = re.compile(r"^digest-\w+__") + + +def _js_number_or_raw(value: Any, default: Any = 0) -> Any: + """复现 JS 的 `Number(x) → isFinite ? 数字 : 原值` 语义。 + + `format_play_count` 返回的是 `"1.2万"` 这类字符串,`Number("1.2万")` 是 NaN, + 原实现于是把原字符串原样传下去;能解析成数字的才转成数字。 + """ + if value is None or value == "": + return default + try: + num = float(value) + except (TypeError, ValueError): + return value + if not math.isfinite(num): + return value + return int(num) if num.is_integer() else num + + +def _to_number(value: Any) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _to_int(value: Any) -> int: + return int(_to_number(value)) + + +def _first_non_empty(*values: Any) -> str: + for value in values: + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + +def normalize_playlist_id(playlist_id: Any) -> str: + """歌单链接 → 裸 id;digest 前缀形态原样保留(交给 song_list 分流)。""" + raw = "" if playlist_id is None else str(playlist_id).strip() + if not raw: + return "" + for pattern in _ID_PATTERNS: + match = pattern.search(raw) + if match: + return match.group(1) + return raw + + +def _to_summary(item: dict[str, Any]) -> dict[str, Any]: + """歌单摘要:在原始条目上补齐契约要求的 id/name/cover/trackCount/… 字段。""" + playlist_id = str(item.get("id") or "").strip() + plain_id = _DIGEST_PREFIX_RE.sub("", playlist_id) + return { + **item, + "id": playlist_id, + "name": str(item.get("name") or "").strip(), + "cover": _first_non_empty(item.get("img"), item.get("cover")), + # 原实现用 `??` 而非 `||`,total 为 0 时不会退化到 trackCount + "trackCount": _to_int(item["total"] if item.get("total") is not None else item.get("trackCount")) or 0, + "playCount": _js_number_or_raw(item.get("play_count")), + "creator": _first_non_empty(item.get("author"), item.get("creator"), item.get("uname")), + "description": _first_non_empty(item.get("desc"), item.get("description")), + "link": f"http://www.kuwo.cn/playlist_detail/{plain_id}" if plain_id else "", + "source": "kw", + } + + +def _windowed(result: dict[str, Any], page: int, limit: int) -> dict[str, Any]: + """推荐/分类接口的 limit 由服务端固定,显式更小时本地截断。""" + requested = _to_int(limit) + server_limit = _to_int(result.get("limit")) + songs = [item for item in (_to_summary(raw) for raw in result.get("list") or []) if item["id"]] + if requested > 0 and len(songs) > requested: + songs = songs[:requested] + effective = min(requested, server_limit) if requested > 0 and server_limit > 0 else server_limit + return { + "source": "kw", + "list": songs, + "page": page, + "limit": effective or len(songs), + "total": _to_int(result.get("total")) or len(songs), + } + + +async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + word = str(keyword or "").strip() + if not word: + return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0} + res = await song_list.search(word, page, limit) + songs = [item for item in (_to_summary(raw) for raw in res.get("list") or []) if item["id"]] + return { + "source": "kw", + "list": songs, + "page": page, + "limit": limit, + "total": _to_int(res.get("total")) or len(songs), + } + + +async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]: + raw_id = normalize_playlist_id(playlist_id) + if not raw_id: + raise RuntimeError("酷我歌单 id 为空") + page_no = max(1, _to_int(page) or 1) + page_size = max(1, _to_int(limit) or 50) + start = (page_no - 1) * page_size + + server_page = start // SERVER_PAGE_SIZE + 1 + skip = start % SERVER_PAGE_SIZE + songs: list[dict[str, Any]] = [] + info: dict[str, Any] | None = None + total = 0 + while len(songs) < page_size and server_page <= MAX_SERVER_PAGES: + res = await song_list.get_list_detail(raw_id, server_page) + if not res: + break + if info is None: + info = res.get("info") + total = _to_int(res.get("total")) or total + raw_list = res.get("list") or [] + if not raw_list: + break + songs.extend(raw_list[skip : skip + (page_size - len(songs))]) + skip = 0 + if len(raw_list) < SERVER_PAGE_SIZE: + break + if total > 0 and server_page * SERVER_PAGE_SIZE >= total: + break + server_page += 1 + + info = info or {} + return { + "source": "kw", + "info": { + "name": _first_non_empty(info.get("name"), raw_id), + "img": _first_non_empty(info.get("img")), + "desc": _first_non_empty(info.get("desc")), + "author": _first_non_empty(info.get("author")), + "play_count": info.get("play_count", ""), + }, + "list": songs, + "page": page_no, + "limit": page_size, + "total": total or len(songs), + } + + +async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]: + """聚合全部页曲目并按 songmid 去重。""" + raw_id = normalize_playlist_id(playlist_id) + if not raw_id: + raise RuntimeError("酷我歌单 id 为空") + songs: list[dict[str, Any]] = [] + seen: set[str] = set() + for server_page in range(1, MAX_SERVER_PAGES + 1): + res = await song_list.get_list_detail(raw_id, server_page) + raw_list = res.get("list") or [] + for song in raw_list: + key = str(song.get("songmid") or "") + if key and key in seen: + continue + if key: + seen.add(key) + songs.append(song) + total = _to_int(res.get("total")) + if len(raw_list) < SERVER_PAGE_SIZE: + break + if total > 0 and len(songs) >= total: + break + return songs + + +async def get_categories() -> dict[str, Any]: + tags_data = await song_list.get_tags() + hot_ids = {str(item.get("id") or "") for item in tags_data.get("hotTag") or []} + result: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}] + for group in tags_data.get("tags") or []: + for item in group.get("list") or []: + tag_id = str(item.get("id") or "").strip() + name = str(item.get("name") or "").strip() + if not tag_id or not name: + continue + result.append( + { + "id": tag_id, + "name": name, + "group": str(group.get("name") or "").strip() or "其他", + "hot": tag_id in hot_ids, + } + ) + return {"source": "kw", "list": result} + + +async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + tag_id = str(category_id or "").strip() + page_no = max(1, _to_int(page) or 1) + res = await song_list.get_list("hot", tag_id or None, page_no) + return _windowed(res, page_no, limit) + + +async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]: + page_no = max(1, _to_int(page) or 1) + res = await song_list.get_list("hot", None, page_no) + return _windowed(res, page_no, limit) + + +async def parse_playlist_link(link: str) -> dict[str, Any]: + raw = str(link or "") + for pattern in _ID_PATTERNS: + match = pattern.search(raw) + if match: + return await get_playlist_detail(match.group(1), 1) + raise RuntimeError("无效的酷我歌单链接") + + +__all__ = [ + "get_categories", + "get_category_playlists", + "get_playlist_detail", + "get_playlist_songs", + "get_recommended", + "normalize_playlist_id", + "parse_playlist_link", + "search_playlist", +] diff --git a/app/sdk/platforms/kw/song_list.py b/app/sdk/platforms/kw/song_list.py new file mode 100644 index 0000000..239f414 --- /dev/null +++ b/app/sdk/platforms/kw/song_list.py @@ -0,0 +1,517 @@ +"""酷我歌单广场与歌单详情(对应 sdk/kw/songList.ts)。 + +四条互不相同的上游链路,按歌单 id 形态分流(`get_list_detail`): + * `digest-__` —— 广场列表返回的内部 id,digest 决定走哪个接口: + 13 → 专辑(转交 album.get_album_list_detail) + 5 → 旧歌单(先 qukudata 查 sourceid,再用 nplserver 取曲目) + 其余 → nplserver 直取 + * `/bodian/` 链接 —— BD 接口(bd-api.kuwo.cn),需要 reqId 与 h5 头 + * 网页链接 —— 正则抽出裸 id 后按 nplserver 取 + +nplserver 固定 1000 条/页,因此详情类调用都在调用方(playlist.py)做本地开窗。 +""" + +from __future__ import annotations + +import asyncio +import random +import re +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import decode_name, format_play_count, format_play_time +from app.sdk.platforms.kw.util import format_singer, obj_str2json + +LIST_LIMIT = 36 +SONG_LIMIT = 1000 +SUCCESS_CODE = 200 + +SORT_LIST: list[dict[str, str]] = [ + {"name": "最新", "id": "new"}, + {"name": "最热", "id": "hot"}, +] + +# 路由层以 `song_list.sort_list[0]["id"]` 取默认排序,名字沿用原 TS 的 sortList +sort_list = SORT_LIST + +TAGS_API = ( + "http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist" + "&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0" + "&loginUid=0&loginSid=0&appUid=76039576" +) +HOT_TAG_API = "http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576" +RCM_LIST_API = ( + "http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0" + "&appUid=76039576&&pn={page}&rn={rn}&order={sort}" +) +TAG_LIST_API = ( + "http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0" + "&appUid=76039576&pn={page}&id={id}&rn={rn}" +) +QZ_LIST_API = "http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id={id}&prod=pc" +LIST_DETAIL_API = ( + "http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}" + "&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1" +) +DIGEST5_INFO_API = "http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node={id}&pn=0&rn=1&fmt=json&src=mbox&level=2" +DIGEST5_MUSIC_API = ( + "http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}" + "&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1" +) +BD_INFO_API = "https://bd-api.kuwo.cn/api/service/playlist/info/{id}?reqId={req_id}&source={source}" +BD_USER_API = "https://bd-api.kuwo.cn/api/ucenter/users/pub/{id}?reqId={req_id}" +BD_LIST_API = ( + "https://bd-api.kuwo.cn/api/service/playlist/{id}/musicList?reqId={req_id}&source={source}&pn={page}&rn={rn}" +) +SEARCH_API = ( + "http://search.kuwo.cn/r.s?all={keyword}&pn={pn}&rn={rn}&rformat=json&encoding=utf8" + "&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0" +) + +BD_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36" + ), + "plat": "h5", +} + +_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"} +_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)") +_DETAIL_LINK_RE = re.compile(r"^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)") +_LINKISH_RE = re.compile(r"[?&:/]") +_DIGEST_RE = re.compile(r"^digest-") +_BD_RE = re.compile(r"/bodian/") +_DIGEST_SPLIT_RE = re.compile(r"__") + + +def _to_int(value: Any) -> int: + try: + return int(float(value)) + except (TypeError, ValueError): + return 0 + + +def _as_str(value: Any) -> str: + return "" if value is None else str(value) + + +def _get_list_url(sort_id: str, playlist_id: str | None, digest: str, page: int) -> str: + if not playlist_id: + return RCM_LIST_API.format(page=page, rn=LIST_LIMIT, sort=sort_id) + if digest == "10000": + return TAG_LIST_API.format(page=page, id=playlist_id, rn=LIST_LIMIT) + if digest == "43": + return QZ_LIST_API.format(id=playlist_id) + # 原文此处无 return,httpFetch(undefined) 会直接抛错;这里显式抛同样的异常语义 + raise RuntimeError(f"不支持的酷我歌单标签类型: {digest}") + + +def _filter_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in raw_list: + types: list[dict[str, str]] = [] + _types: dict[str, dict[str, str]] = {} + for entry in str(item.get("N_MINFO") or "").split(";"): + match = _MININFO_RE.search(entry) + if match is None: + continue + quality = _BITRATE_QUALITY.get(match.group(2)) + if quality is None: + continue + types.append({"type": quality, "size": match.group(4)}) + _types[quality] = {"size": match.group(4).upper()} + types.reverse() + + result.append( + { + "singer": format_singer(decode_name(item.get("artist"))), + "name": decode_name(item.get("name")), + "albumName": decode_name(item.get("album")), + "albumId": item.get("albumid"), + "songmid": item.get("id"), + "source": "kw", + "interval": format_play_time(_to_int(item.get("duration"))), + "img": None, + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return result + + +def _filter_list(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "play_count": format_play_count(_to_int(item.get("listencnt"))), + "id": f"digest-{item.get('digest')}__{item.get('id')}", + "author": item.get("uname"), + "name": item.get("name"), + "total": item.get("total"), + "img": item.get("img"), + "grade": _to_int(item.get("favorcnt")) / 10, + "desc": item.get("desc"), + "source": "kw", + } + for item in raw_list + ] + + +def _filter_list2(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]: + """qz_data 接口返回按 label 分组的二维结构,逐组展开成一维。 + + 原实现用 `a && f(b)` 做兜底:条件为真才格式化,否则原样返回条件值。 + play_count 用 `item.play_count` 作条件却格式化 `item.listencnt`,逐个照搬。 + """ + result: list[dict[str, Any]] = [] + for group in raw_data: + if not group.get("label"): + continue + for item in group.get("list") or []: + raw_play = item.get("play_count") + raw_favor = item.get("favorcnt") + result.append( + { + "play_count": format_play_count(_to_int(item.get("listencnt"))) if raw_play else (raw_play or 0), + "id": f"digest-{item.get('digest')}__{item.get('id')}", + "author": item.get("uname"), + "name": item.get("name"), + "total": item.get("total"), + "img": item.get("img"), + "grade": _to_int(raw_favor) / 10 if raw_favor else (raw_favor or 0), + "desc": item.get("desc"), + "source": "kw", + } + ) + return result + + +def _detail_info(body: dict[str, Any]) -> dict[str, Any]: + return { + "name": body.get("title"), + "img": body.get("pic"), + "desc": body.get("info"), + "author": body.get("uname"), + "play_count": format_play_count(_to_int(body.get("playnum"))), + } + + +async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(TAGS_API) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_tag(retry_num + 1) + return [ + { + "name": group.get("name"), + "list": [ + { + "parent_id": group.get("id"), + "parent_name": group.get("name"), + "id": f"{item.get('id')}-{item.get('digest')}", + "name": item.get("name"), + "source": "kw", + } + for item in group.get("data") or [] + ], + } + for group in body.get("data") or [] + ] + + +async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(HOT_TAG_API) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_hot_tag(retry_num + 1) + groups = body.get("data") or [{}] + return [ + {"id": f"{item.get('id')}-{item.get('digest')}", "name": item.get("name"), "source": "kw"} + for item in (groups[0] or {}).get("data") or [] + ] + + +async def get_tags() -> dict[str, Any]: + tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag()) + return {"tags": tags, "hotTag": hot_tag, "source": "kw"} + + +async def get_list(sort_id: str, tag_id: str | None = None, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + playlist_id: str | None = None + digest = "" + if tag_id: + parts = tag_id.split("-") + playlist_id = parts[0] + digest = parts[1] if len(parts) > 1 else "" + + url = _get_list_url(sort_id, playlist_id, digest, page) + resp = await http_fetch(url) + body = resp.body + + if not playlist_id or digest == "10000": + if not isinstance(body, dict) or body.get("code") != SUCCESS_CODE: + return await get_list(sort_id, tag_id, page, retry_num + 1) + data = body.get("data") or {} + return { + "list": _filter_list(data.get("data") or []), + "total": data.get("total"), + "page": data.get("pn"), + "limit": data.get("rn"), + "source": "kw", + } + if not isinstance(body, list) or not body: + return await get_list(sort_id, tag_id, page, retry_num + 1) + return {"list": _filter_list2(body), "total": SONG_LIMIT, "page": page, "limit": SONG_LIMIT, "source": "kw"} + + +async def get_list_detail_digest8(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(LIST_DETAIL_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT)) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("result") != "ok": + return await get_list_detail(playlist_id, page, retry_num + 1) + return { + "list": _filter_list_detail(body.get("musiclist") or []), + "page": page, + "limit": body.get("rn"), + "total": body.get("total"), + "source": "kw", + "info": _detail_info(body), + } + + +async def get_list_detail_digest5_info(playlist_id: str, retry_num: int = 0) -> str | None: + """digest=5 的歌单 id 不能直接给 nplserver,要先查出真正的 sourceid。""" + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(DIGEST5_INFO_API.format(id=playlist_id)) + body = resp.body if isinstance(resp.body, dict) else {} + children = body.get("child") + if resp.status_code != 200 or not children: + return await get_list_detail_digest5_info(playlist_id, retry_num + 1) + return str(children[0].get("sourceid")) if children else None + + +async def get_list_detail_digest5_music(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(DIGEST5_MUSIC_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT)) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("result") != "ok": + return await get_list_detail(playlist_id, page, retry_num + 1) + return { + "list": _filter_list_detail(body.get("musiclist") or []), + "page": page, + "limit": body.get("rn"), + "total": body.get("total"), + "source": "kw", + "info": _detail_info(body), + } + + +async def get_list_detail_digest5(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]: + detail_id = await get_list_detail_digest5_info(playlist_id, retry_num) + if detail_id is None: + raise RuntimeError("failed") + return await get_list_detail_digest5_music(detail_id, page, retry_num) + + +def _filter_bd_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + for item in raw_list: + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for audio in item.get("audios") or []: + size = _as_str(audio.get("size")).upper() if audio.get("size") is not None else None + quality = _BITRATE_QUALITY.get(_as_str(audio.get("bitrate"))) + if quality is None: + continue + types.append({"type": quality, "size": size}) + _types[quality] = {"size": size} + types.reverse() + + result.append( + { + "singer": "、".join(_as_str(s.get("name")) for s in item.get("artists") or []), + "name": item.get("name"), + "albumName": item.get("album"), + "albumId": item.get("albumId"), + "songmid": item.get("id"), + "source": "kw", + "interval": format_play_time(_to_int(item.get("duration"))), + "img": item.get("albumPic"), + "releaseDate": item.get("releaseDate"), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return result + + +def _get_req_id() -> str: + """BD 接口的 reqId:八段随机 16 进制。 + + 原实现 `(65536 * (1 + Math.random()) | 0).toString(16).substring(1)`: + 值落在 [0x10000, 0x1ffff],固定 5 位 16 进制,去掉首位后取 4 位。 + """ + + def segment() -> str: + return f"{int(65536 * (1 + random.random())):05x}"[1:] + + return "".join(segment() for _ in range(8)) + + +async def _bd_playlist_info(playlist_id: str, source: str) -> dict[str, Any] | None: + resp = await http_fetch(BD_INFO_API.format(id=playlist_id, req_id=_get_req_id(), source=source), headers=BD_HEADERS) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != 200: + return None + data = body.get("data") or {} + return { + "name": data.get("name"), + "img": data.get("pic"), + "desc": data.get("description"), + "author": data.get("creatorName"), + "play_count": data.get("playNum"), + } + + +async def _bd_user_pub_info(user_id: str) -> dict[str, Any] | None: + resp = await http_fetch(BD_USER_API.format(id=user_id, req_id=_get_req_id()), headers=BD_HEADERS) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != 200: + return None + info = (body.get("data") or {}).get("userInfo") or {} + nickname = _as_str(info.get("nickname")) + return { + "name": nickname + "喜欢的音乐", + "img": info.get("headImg"), + "desc": "", + "author": nickname, + "play_count": "", + } + + +async def _bd_playlist_songs(playlist_id: str, source: str, page: int, retry_num: int = 0) -> dict[str, Any]: + try: + resp = await http_fetch( + BD_LIST_API.format(id=playlist_id, req_id=_get_req_id(), source=source, page=page, rn=SONG_LIMIT), + headers=BD_HEADERS, + ) + except Exception as err: + if retry_num > 2: + raise RuntimeError("try max num") from err + return await _bd_playlist_songs(playlist_id, source, page, retry_num + 1) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != 200: + raise RuntimeError("failed") + data = body.get("data") or {} + return { + "list": _filter_bd_list_detail(data.get("list") or []), + "page": page, + "limit": data.get("pageSize"), + "total": data.get("total"), + "source": "kw", + } + + +def _match_group(link: str, pattern: str) -> str | None: + found = re.search(pattern, link) + return found.group(1) if found else None + + +async def _get_list_detail_by_bd(raw_id: str, page: int) -> dict[str, Any]: + """bodian 分享链接:uid/playlistId/source 都在 query 里,缺 source 时按原实现拼字面量。""" + uid = _match_group(raw_id, r"uid=(\d+)") + playlist_id = _match_group(raw_id, r"playlistId=(\d+)") + source = _match_group(raw_id, r"source=(\d+)") or "undefined" + if not playlist_id: + raise RuntimeError("failed") + + tasks: list[Any] = [_bd_playlist_songs(playlist_id, source, page)] + if source == "4": + tasks.append(_bd_playlist_info(playlist_id, source)) + elif source == "5": + tasks.append(_bd_user_pub_info(uid or playlist_id)) + results = await asyncio.gather(*tasks) + list_data = results[0] + info = results[1] if len(results) > 1 else None + list_data["info"] = info or {"name": "", "img": "", "desc": "", "author": "", "play_count": ""} + return list_data + + +async def get_list_detail(playlist_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + if _BD_RE.search(playlist_id): + return await _get_list_detail_by_bd(playlist_id, page) + if _LINKISH_RE.search(playlist_id): + playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id) + elif _DIGEST_RE.search(playlist_id): + parts = _DIGEST_SPLIT_RE.split(playlist_id) + digest = parts[0].replace("digest-", "") + playlist_id = parts[1] if len(parts) > 1 else "" + if digest == "13": + from app.sdk.platforms.kw import album + + return await album.get_album_list_detail(playlist_id, page, retry_num) + # 原文 switch 里只有 '8' 会 break 落到 digest8,其余(含 '5' 与未知 digest)全走 digest5 + if digest == "8": + return await get_list_detail_digest8(playlist_id, page, retry_num) + return await get_list_detail_digest5(playlist_id, page, retry_num) + return await get_list_detail_digest8(playlist_id, page, retry_num) + + +async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + resp = await http_fetch(SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=limit)) + body = obj_str2json(resp.raw.decode("utf-8", errors="replace")) + return { + "list": [ + { + "play_count": format_play_count(_to_int(item.get("playcnt"))), + "id": _as_str(item.get("playlistid")), + "author": decode_name(item.get("nickname")), + "name": decode_name(item.get("name")), + "total": item.get("songnum"), + "img": item.get("pic"), + "desc": decode_name(item.get("intro")), + "source": "kw", + } + for item in body.get("abslist") or [] + ], + "limit": limit, + "total": _to_int(body.get("TOTAL")), + "source": "kw", + } + + +def get_detail_page_url(playlist_id: str) -> str: + if _LINKISH_RE.search(playlist_id): + playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id) + elif _DIGEST_RE.search(playlist_id): + parts = playlist_id.split("__") + playlist_id = parts[1] if len(parts) > 1 else "" + return f"http://www.kuwo.cn/playlist_detail/{playlist_id}" + + +__all__ = [ + "SORT_LIST", + "get_detail_page_url", + "get_hot_tag", + "get_list", + "get_list_detail", + "get_tag", + "get_tags", + "search", +] diff --git a/app/sdk/platforms/kw/util.py b/app/sdk/platforms/kw/util.py new file mode 100644 index 0000000..af15736 --- /dev/null +++ b/app/sdk/platforms/kw/util.py @@ -0,0 +1,192 @@ +"""酷我平台工具(对应 sdk/kw/util.ts)。 + +四块内容:legacy 接口的 JS 对象字面量转 JSON、歌手名格式化、逐字歌词(lrcx)解析、 +wbd 榜单接口的 AES-128-ECB 加解密与签名。 +""" + +from __future__ import annotations + +import base64 +import json +import math +import random +import re +import time +from typing import Any +from urllib.parse import quote, unquote + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import to_md5 + +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" +MOBI_UA = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 " + "(KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36" +) +HOME_URL = "https://www.kuwo.cn/" + +# 单引号替换:原文是一条含变长后顾断言的正则,Python re 不支持变长 lookbehind, +# 因此拆成「引号前是 {,: 」与「引号后是 },: 」两步,覆盖范围与原文等价。 +_QUOTE_START = re.compile(r"(?P
    [{:,]\s*)'")
    +_QUOTE_END = re.compile(r"'(?P\s*[}:,])")
    +
    +
    +def obj_str2json(text: str) -> Any:
    +    """legacy r.s 返回的是 JS 对象字面量(单引号 + 未加引号的形态),先转成合法 JSON。
    +
    +    专辑详情实测确认仍是这一形态,不能直接 json.loads。
    +    """
    +    fixed = _QUOTE_START.sub(r'\g
    "', text)
    +    fixed = _QUOTE_END.sub(r'"\g', fixed)
    +    return json.loads(fixed)
    +
    +
    +def format_singer(raw: str) -> str:
    +    """酷我用 `&` 分隔合唱歌手,统一换成顿号。"""
    +    return raw.replace("&", "、")
    +
    +
    +# ---------- 逐字歌词 ----------
    +
    +_WORD_LINE = re.compile(r"^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*")
    +_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
    +_WORD_TIME = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
    +_LINE_SPLIT = re.compile(r"\r\n|\r|\n")
    +
    +
    +def _js_number(value: float) -> str:
    +    """JS 的 Number → String:整数值不带小数点,其余取最短往返表示(与 repr 同)。"""
    +    return str(int(value)) if float(value).is_integer() else repr(float(value))
    +
    +
    +class LrcTools:
    +    """lrcx 逐字歌词解析器。原实现靠 Object.create 做每轮隔离,这里用新实例。"""
    +
    +    def __init__(self) -> None:
    +        self.offset = 1
    +        self.offset2 = 1
    +        self.is_ok = True
    +        self.lines: list[str] = []
    +        self.tags: list[str] = []
    +
    +    def get_word_info(self, first: str, second: str, prev: dict[str, Any] | None) -> dict[str, Any]:
    +        offset = int(first)
    +        offset2 = int(second)
    +        start = abs((offset + offset2) / (self.offset * 2))
    +        end = abs((offset - offset2) / (self.offset2 * 2)) + start
    +        if prev is not None and start < prev["end"]:
    +            # 上一句被本句起点截断,需要回头改写已拼进 words 的那一段
    +            prev["end"] = start
    +            if prev["start"] > prev["end"]:
    +                prev["start"] = prev["end"]
    +            prev["new"] = f"<{_js_number(prev['start'])},{_js_number(prev['end'] - prev['start'])}>"
    +        return {"start": start, "end": end, "text": f"<{_js_number(start)},{_js_number(end - start)}>"}
    +
    +    def parse_line(self, line: str) -> None:
    +        if len(line) < 6:
    +            return
    +        match = _WORD_LINE.match(line)
    +        if match:
    +            head = match.group(1)
    +            words = match.group(2) or ""
    +            spans = [item.group(0) for item in _WORD_TIME.finditer(words)]
    +            if not spans:
    +                return
    +            prev: dict[str, Any] | None = None
    +            for span in spans:
    +                found = _WORD_TIME.search(span)
    +                if found is None:
    +                    continue
    +                info = self.get_word_info(found.group(1), found.group(2), prev)
    +                words = words.replace(span, info["text"], 1)
    +                if prev is not None and prev.get("new"):
    +                    words = words.replace(prev["text"], prev["new"], 1)
    +                prev = info
    +            self.lines.append(head + words)
    +            return
    +        tag = _TAG_LINE.search(line)
    +        if tag is None:
    +            return
    +        if tag.group(1) != "kuwo":
    +            self.tags.append(line)
    +            return
    +        content = tag.group(2)
    +        if "][" in content:
    +            content = content[: content.index("][")]
    +        try:
    +            value = int(content.strip(), 8)
    +        except ValueError:
    +            self.is_ok = False
    +            return
    +        self.offset = int(value / 10)
    +        self.offset2 = value % 10
    +        # 原文对 NaN/0 一律判失败,等价于这里两个偏移都非零
    +        if self.offset == 0 or self.offset2 == 0:
    +            self.is_ok = False
    +
    +
    +def parse_yrc(lrc: str) -> str:
    +    """把歌词里的 lrcx 时间标记规范化;失败抛 RuntimeError(原文文案 failed)。"""
    +    tools = LrcTools()
    +    for line in _LINE_SPLIT.split(lrc):
    +        if not tools.is_ok:
    +            raise RuntimeError("failed")
    +        tools.parse_line(line)
    +    if not tools.lines:
    +        return ""
    +    merged = "\n".join(tools.lines)
    +    if tools.tags:
    +        merged = "\n".join(tools.tags) + "\n" + merged
    +    return merged
    +
    +
    +# ---------- wbd 榜单接口加解密 ----------
    +
    +WBD_APP_ID = "y67sprxhhpws"
    +_WBD_KEY = bytes([112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228])
    +
    +
    +def _aes_ecb(data: bytes, *, encrypt: bool) -> bytes:
    +    cipher = Cipher(algorithms.AES(_WBD_KEY), modes.ECB())
    +    if encrypt:
    +        pad = 16 - len(data) % 16
    +        padded = data + bytes([pad]) * pad
    +        context = cipher.encryptor()
    +        return context.update(padded) + context.finalize()
    +    context = cipher.decryptor()
    +    out = context.update(data) + context.finalize()
    +    pad = out[-1] if out else 0
    +    return out[:-pad] if 0 < pad <= 16 else out
    +
    +
    +def wbd_decode_data(payload: str) -> Any:
    +    """解码 wbd 响应:URL 编码的 base64 → AES-128-ECB 解密 → JSON。"""
    +    raw = base64.b64decode(unquote(payload))
    +    return json.loads(_aes_ecb(raw, encrypt=False).decode())
    +
    +
    +def wbd_build_param(json_data: Any) -> str:
    +    """构造 wbd 请求 query:加密体 + 毫秒时间戳 + appId + MD5 大写签名。"""
    +    data = json.dumps(json_data, ensure_ascii=False, separators=(",", ":")).encode()
    +    time_ms = int(time.time() * 1000)
    +    encoded = base64.b64encode(_aes_ecb(data, encrypt=True)).decode()
    +    sign = to_md5(f"{WBD_APP_ID}{encoded}{time_ms}").upper()
    +    return f"data={quote(encoded, safe='')}&time={time_ms}&appId={WBD_APP_ID}&sign={sign}"
    +
    +
    +async def fetch_home_set_cookie() -> list[str]:
    +    """取酷我首页下发的全部 Set-Cookie。
    +
    +    用 http_fetch 的 set_cookies(原始多值头),而不是折叠后的 headers dict——
    +    Secret 签名必须认出动态下发的 Hm_Iuvt_* cookie(名字与值均随机),
    +    同名头被折叠会恰好丢掉目标 cookie。
    +    """
    +    resp = await http_fetch(HOME_URL, headers={"User-Agent": UA}, timeout=15_000)
    +    return list(resp.set_cookies)
    +
    +
    +def random_digits() -> int:
    +    """JS 的 Math.round(1e9 * Math.random()) —— round 是四舍五入,不是 Python 的银行家舍入。"""
    +    return math.floor(1e9 * random.random() + 0.5)
    diff --git a/app/sdk/platforms/lr/__init__.py b/app/sdk/platforms/lr/__init__.py
    new file mode 100644
    index 0000000..91af246
    --- /dev/null
    +++ b/app/sdk/platforms/lr/__init__.py
    @@ -0,0 +1,80 @@
    +"""懒人听书(lrts.me)平台模块(对应 sdk/lr/index.ts)。
    +
    +匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
    +取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
    +章节 songmid 为复合 id ``:
    ``;有声内容不参与聚合(noAggregate)。 +""" + +from __future__ import annotations + +import math +from typing import Any + +from app.sdk.platforms._base import Platform +from app.sdk.platforms.lr import menu, music_search + + +async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + from app.sdk.apis.lr import get_music_url + + return await get_music_url(song_info, quality, retry_num) + + +async def get_pic(_song_info: dict[str, Any]) -> None: + """书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回 None 由调用方回退)。""" + return None + + +async def get_album_detail(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]: + result = await menu.get_book_menu(book_id, page, limit) + total = result["total"] + songs = [] + for item in result["list"][:limit]: + if not isinstance(item, dict): + continue + section = item.get("section") + songs.append( + { + "songmid": f"{book_id}:{section}", + "name": item.get("name") or f"第{section}节", + "singer": "", + "albumId": str(book_id), + "albumName": "", + "interval": 0, + "payPlay": str(item.get("listenPrice")) != "0", + "img": None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + return { + "info": {"name": f"书籍 {book_id}", "total": total}, + "list": songs, + "total": total, + "limit": limit, + "allPage": math.ceil(total / 30) or page, + "source": "lr", + } + + +class _LrAlbum: + get_album_detail = staticmethod(get_album_detail) + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + book_id = str(song_info.get("songmid") or "").split(":")[0] + return f"https://m.lrts.me/book/{book_id}" + + +MODULE = Platform( + id="lr", + name="懒人听书", + music_search=music_search, + album=_LrAlbum, + get_music_url=_get_music_url, + get_pic=get_pic, + get_music_detail_page_url=get_music_detail_page_url, +) diff --git a/app/sdk/platforms/lr/app_api.py b/app/sdk/platforms/lr/app_api.py new file mode 100644 index 0000000..69941af --- /dev/null +++ b/app/sdk/platforms/lr/app_api.py @@ -0,0 +1,47 @@ +"""懒人听书 App API(对应 sdk/lr/appApi.ts,dapis.mting.info)—— 搜索通道(游客 token 匿名可用)。 + +签名(移植 loyalflower/lrts_download download.go): + sc = md5(urlPath + "?" + <按 key 排序的 query(不含 sc) 拼接> + secret),secret = "iJ0DgxmdC83#I&j@iwg" + 头:UA Android13/yyting/... / Referer yytingting.com / ClientVersion 6.3.4.0 +端点:/yyting/search/searchBatch.action(q=2150) + +注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.py)。 +""" + +from __future__ import annotations + +import hashlib +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch + +DOMAIN = "http://dapis.mting.info" +SECRET = "iJ0DgxmdC83#I&j@iwg" +GUEST_TOKEN = "OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU" +GUEST_IMEI = "MDI6MDA6MDA6MDA6MDA6DDA=" + +APP_HEADERS = { + "User-Agent": "Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android", + "Referer": "yytingting.com", + "ClientVersion": "6.3.4.0", +} + + +def encode_uri_component(value: Any) -> str: + return quote(str(value), safe="!~*'()") + + +def signed(path: str, params: dict[str, str]) -> str: + query: dict[str, str] = {"token": GUEST_TOKEN, "imei": GUEST_IMEI, "nwt": "1", **params} + qs = "&".join(f"{key}={query[key]}" for key in sorted(query)) + query["sc"] = hashlib.md5(f"{path}?{qs}{SECRET}".encode()).hexdigest() + return f"{DOMAIN}{path}?" + "&".join(f"{key}={encode_uri_component(value)}" for key, value in query.items()) + + +async def app_api_get(path: str, params: dict[str, str]) -> dict[str, Any]: + """App API GET(游客态,仅搜索可用;章节/取流会 482 需登录)。""" + resp = await http_fetch(signed(path, params), headers=APP_HEADERS, timeout=15_000) + if resp.status_code != 200: + raise RuntimeError(f"懒人 App API HTTP {resp.status_code}") + return resp.body if isinstance(resp.body, dict) else {} diff --git a/app/sdk/platforms/lr/menu.py b/app/sdk/platforms/lr/menu.py new file mode 100644 index 0000000..497ef5e --- /dev/null +++ b/app/sdk/platforms/lr/menu.py @@ -0,0 +1,33 @@ +"""懒人听书章节列表(对应 sdk/lr/menu.ts,web 通道,匿名免签)。 + +接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0 +实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state} +注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import as_dict, as_list + +UA_MI = ( + "Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 " + "(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0" +) +MENU_URL = "https://m.lrts.me/ajax/getBookMenu" + + +async def get_book_menu(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]: + resp = await http_fetch( + f"{MENU_URL}?bookId={book_id}&pageNum={page}&pageSize={max(30, limit)}&sortType=0", + headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/"}, + timeout=15_000, + ) + if resp.status_code != 200: + raise RuntimeError(f"获取章节失败(HTTP {resp.status_code})") + body = as_dict(resp.body) + if body.get("status") == 1 or not body.get("list"): + raise RuntimeError(str(body.get("msg") or "获取章节失败")) + return {"total": int(body.get("sections") or 0), "list": as_list(body.get("list"))} diff --git a/app/sdk/platforms/lr/music_search.py b/app/sdk/platforms/lr/music_search.py new file mode 100644 index 0000000..66eb5c1 --- /dev/null +++ b/app/sdk/platforms/lr/music_search.py @@ -0,0 +1,58 @@ +"""懒人听书搜索(对应 sdk/lr/musicSearch.ts,App 通道,游客匿名)。 + +接口: /yyting/search/searchBatch.action?keyWord=&searchOption=1&pageSize=&type=0&q=2150 +实测(2026-10-02)匿名可用;结果分 data.bookResult/albumResult/folderResult/... +书籍条目字段:id(baseEntityId 同值)/name/announcer/cover +""" + +from __future__ import annotations + +import math +from typing import Any + +from app.sdk.common.utils import as_dict, as_list +from app.sdk.platforms.lr.app_api import app_api_get + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + del retry_num # 契约参数,游客搜索无重试语义 + body = await app_api_get( + "/yyting/search/searchBatch.action", + { + "keyWord": keyword, + "searchOption": "1", + "pageSize": str(limit), + "type": str(page - 1), + "q": "2150", + }, + ) + api_status = body.get("apiStatus") + if api_status is not None and api_status != 0: + raise RuntimeError(str(body.get("msg") or f"搜索失败(code {api_status})")) + book_result = as_dict(as_dict(body.get("data")).get("bookResult")) + books = [item for item in as_list(book_result.get("list")) if isinstance(item, dict)] + total = int(book_result.get("count") or len(books)) + return { + "allPage": math.ceil(total / limit) or page, + "total": total, + "limit": limit, + "source": "lr", + "list": [ + { + "songmid": str(item.get("id", item.get("baseEntityId"))), + "name": item.get("name") or "", + "singer": item.get("announcer") or item.get("author") or "", + "albumId": "", + "albumName": "", + "interval": 0, + "payPlay": False, + "img": item.get("cover") or None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + for item in books + ], + } diff --git a/app/sdk/platforms/me/__init__.py b/app/sdk/platforms/me/__init__.py new file mode 100644 index 0000000..0f531ac --- /dev/null +++ b/app/sdk/platforms/me/__init__.py @@ -0,0 +1,55 @@ +"""猫耳FM(MissEvan,ACG 广播剧/音声平台)平台模块(对应 sdk/me/index.ts)。 + +匿名可用面(2026-10-02 实测):搜索 / 剧集列表 / 免费声音明文直链 / 封面。 +取流走 App API 签名通道(app.missevan.com,HMAC-SHA256(appsecret)), +返回 maoercdn 免签名直链——网页版 hls.m3u8 是 Widevine 加密流,勿用 web soundurl。 +付费声音需 POST /login/me 绑定含 token 的 cookie。 +有声内容不参与聚合搜索与跨平台匹配(noAggregate),仅按 source=me 直查。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.auth_store import get_credentials +from app.sdk.platforms._base import Platform +from app.sdk.platforms.me import album, music_search +from app.sdk.platforms.me.music_info import get_sound_info + + +async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + from app.sdk.apis.me import get_music_url + + return await get_music_url(song_info, quality, retry_num) + + +async def get_pic(song_info: dict[str, Any]) -> str | None: + info = await get_sound_info(str(song_info.get("songmid") or "")) + return info.get("front_cover") or None + + +class _MeAlbum: + get_album_detail = staticmethod(album.get_album_detail) + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + return f"https://www.missevan.com/sound/{song_info.get('songmid') or ''}" + + +def status() -> dict[str, Any]: + """登录状态(凭据存在性判断)。""" + cred = get_credentials("me") or {} + if cred.get("cookie"): + return {"logged": True, "updatedAt": cred.get("updatedAt")} + return {"logged": False} + + +MODULE = Platform( + id="me", + name="猫耳FM", + music_search=music_search, + album=_MeAlbum, + get_music_url=_get_music_url, + get_pic=get_pic, + get_music_detail_page_url=get_music_detail_page_url, +) diff --git a/app/sdk/platforms/me/album.py b/app/sdk/platforms/me/album.py new file mode 100644 index 0000000..c3a1deb --- /dev/null +++ b/app/sdk/platforms/me/album.py @@ -0,0 +1,73 @@ +"""猫耳FM 广播剧集列表(对应 sdk/me/album.ts,web 通道,匿名免签)。 + +接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize= +结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name。 +注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法), +本接口支持翻页故用之。 +""" + +from __future__ import annotations + +import math +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import as_dict, as_list, format_play_time + +EPISODES_URL = "https://www.missevan.com/dramaapi/getdramaepisodes" + + +async def get_album_detail(drama_id_raw: str, page: int = 1, limit: int = 30) -> dict[str, Any]: + # 搜索结果的剧条目带 d: 前缀(songmid="d:"),这里剥掉 + drama_id = drama_id_raw[2:] if str(drama_id_raw).startswith("d:") else str(drama_id_raw) + if not re.fullmatch(r"\d+", drama_id): + raise RuntimeError("剧 id 无效") + resp = await http_fetch( + f"{EPISODES_URL}?drama_id={drama_id}&season=1&page={page}&order=0&pageSize={max(30, limit)}", + 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" + ), + "Referer": f"https://www.missevan.com/mdrama/drama/{drama_id}", + "Accept": "application/json", + }, + timeout=15_000, + ) + body = as_dict(resp.body) + if resp.status_code != 200 or body.get("success") is False: + raise RuntimeError(str(body.get("info") or f"获取剧集失败(HTTP {resp.status_code})")) + info = as_dict(body.get("info")) + drama = as_dict(info.get("drama")) + episodes = as_list(as_dict(info.get("episodes")).get("episode")) + songs = [] + for item in episodes[:limit]: + if not isinstance(item, dict): + continue + duration = item.get("duration") + songs.append( + { + "songmid": str(item.get("sound_id")), + "name": item.get("name") or "", + "singer": drama.get("name") or "", + "albumId": str(drama_id), + "albumName": drama.get("name") or "", + "interval": format_play_time(math.floor(float(duration) / 1000 + 0.5)) if duration else 0, + "payPlay": int(item.get("pay_type") or 0) > 0, + "img": drama.get("cover") or None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + return { + "info": {"name": drama.get("name") or str(drama_id), "total": len(episodes)}, + "list": songs, + "total": len(episodes), + "limit": limit, + "allPage": 1, + "source": "me", + } diff --git a/app/sdk/platforms/me/music_info.py b/app/sdk/platforms/me/music_info.py new file mode 100644 index 0000000..dc46df4 --- /dev/null +++ b/app/sdk/platforms/me/music_info.py @@ -0,0 +1,18 @@ +"""猫耳FM 曲目信息(对应 sdk/me/musicInfo.ts,App API 通道)。 + +接口: GET https://app.missevan.com/sound/sound?sound_id= +返回 info 直接是 sound 对象(非 web 版的 info.sound 包装): + soundstr/duration(毫秒)/front_cover/soundurl(原文件)/soundurl_64(=原文件)/ + soundurl_128/-192k 变体,均为 maoercdn.com 免签名明文直链。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms.me.sign import app_api_get + + +async def get_sound_info(sound_id: str) -> dict[str, Any]: + info = await app_api_get("/sound/sound", f"sound_id={sound_id}") + return info if isinstance(info, dict) else {} diff --git a/app/sdk/platforms/me/music_search.py b/app/sdk/platforms/me/music_search.py new file mode 100644 index 0000000..acca1d8 --- /dev/null +++ b/app/sdk/platforms/me/music_search.py @@ -0,0 +1,111 @@ +"""猫耳FM 搜索(对应 sdk/me/musicSearch.ts,双通道聚合,与网页版对齐)。 + +广播剧(置顶): https://www.missevan.com/dramaapi/search?s=&page= → info.Datas[] + ——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型 +声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[] + +剧条目 songmid 用 "d:" 前缀(取流时自动取首个免费集,见 apis/me.py);声音条目为裸 sound_id。 +""" + +from __future__ import annotations + +import asyncio +import math +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import as_dict, as_list, format_play_time +from app.sdk.platforms.fivesing.util import encode_uri_component + +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" +H = {"User-Agent": UA, "Accept": "application/json", "Referer": "https://www.missevan.com/sound/search"} + + +async def _search_dramas(keyword: str, page: int, limit: int) -> list[dict[str, Any]]: + resp = await http_fetch( + f"https://www.missevan.com/dramaapi/search?s={encode_uri_component(keyword)}&page={page}", + headers=H, + timeout=15_000, + ) + body = as_dict(resp.body) + if resp.status_code != 200 or body.get("success") is False: + return [] + datas = as_list(as_dict(body.get("info")).get("Datas")) + return [ + { + "songmid": f"d:{item.get('id')}", + "name": item.get("name") or "", + "singer": item.get("author") or "", + "albumId": str(item.get("id")), + "albumName": item.get("name") or "", + "interval": 0, + "payPlay": True, + "img": item.get("cover") or None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + for item in datas[:limit] + if isinstance(item, dict) + ] + + +async def _search_sounds(keyword: str, page: int, limit: int) -> list[dict[str, Any]]: + resp = await http_fetch( + f"https://www.missevan.com/sound/getsearch?s={encode_uri_component(keyword)}&p={page}&type=3&page_size={limit}&sort=2", + headers=H, + timeout=15_000, + ) + body = as_dict(resp.body) + if resp.status_code != 200 or body.get("success") is False: + return [] + datas = as_list(as_dict(body.get("info")).get("Datas")) + songs = [] + for item in datas: + if not isinstance(item, dict): + continue + duration = item.get("duration") + songs.append( + { + "songmid": str(item.get("id")), + "name": item.get("soundstr") or "", + "singer": item.get("username") or "", + "albumId": "", + "albumName": "", + "interval": format_play_time(math.floor(float(duration) / 1000 + 0.5)) if duration else 0, + "payPlay": int(item.get("pay_type") or 0) > 0, + "img": item.get("front_cover") or None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + return songs + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + """剧优先置顶(约六成配额),零散声音补足;剧集翻页直接传 page。""" + del retry_num # 契约参数,web 搜索无重试语义 + + async def safe(coro: Any) -> list[dict[str, Any]]: + try: + return await coro + except Exception: # noqa: BLE001 - 单通道失败不拖垮搜索 + return [] + + dramas, sounds = await asyncio.gather( + safe(_search_dramas(keyword, page, max(4, int(limit * 0.6)))), + safe(_search_sounds(keyword, page, limit)), + ) + songs = [*dramas, *sounds][:limit] + return { + "allPage": page + (1 if len(songs) >= limit else 0), + "total": len(dramas) + len(sounds), + "limit": limit, + "source": "me", + "list": songs, + } diff --git a/app/sdk/platforms/me/sign.py b/app/sdk/platforms/me/sign.py new file mode 100644 index 0000000..4d1ff99 --- /dev/null +++ b/app/sdk/platforms/me/sign.py @@ -0,0 +1,98 @@ +"""猫耳FM(MissEvan)App API 签名(对应 sdk/me/sign.ts)—— app.missevan.com 域鉴权。 + +算法(知乎 p/359692208 + zunley/gofm pkg/missevan/utils.go,2026-10-02 实测可用): + authorization = "MissEvan " + base64(HMAC-SHA256(appsecret, DATA)) + DATA = METHOD\\n + url(冒号转义%3A)\\n + querystring\\n + equip_id:\\n [+ token:\\n] + + x-m-date:\\n + x-m-nonce:\\n [+ base64(sha256(body))\\n] + 头:User-Agent MissEvanApp、x-m-date、x-m-nonce、Cookie(equip_id[+token]) + +该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体); +网页版 hls.m3u8 为 Widevine 加密,与此无关。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import re +import uuid +from datetime import UTC, datetime +from typing import Any + +from app.sdk.auth_store import get_credentials +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import as_dict + +APP_SECRET = "f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ" +APP_UA = "MissEvanApp/4.7.4 (iOS;15.1;iPhone11,6)" +EQUIP_ID = "52e76e9a-7592-43af-ae72-8ec755094e41" + + +def _utc_millis() -> str: + """``new Date().toISOString().replace(/\\.\\d+Z$/, '.000Z')`` 的等价实现。""" + return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z") + + +def build_auth( + method: str, + url: str, + query: str, + *, + equip_id: str, + token: str, + date: str, + nonce: str, +) -> str: + """构造 authorization 签名(GET 便捷封装;POST 需补 body 摘要行)。""" + lines = [ + method, + url.replace(":", "%3A"), + query, + f"equip_id:{equip_id}", + ] + if token: + lines.append(f"token:{token}") + lines.extend([f"x-m-date:{date}", f"x-m-nonce:{nonce}"]) + digest = hmac.new(APP_SECRET.encode(), ("\n".join(lines) + "\n").encode(), hashlib.sha256).digest() + return "MissEvan " + base64.b64encode(digest).decode() + + +def get_me_token() -> str: + """登录 token(/login/me 绑定的 cookie 中提取;未登录为空)。""" + cookie = str((get_credentials("me") or {}).get("cookie") or "") + match = re.search(r"(?:^|;\s*)token=([^;\s]+)", cookie) + return match.group(1) if match else "" + + +async def app_api_get(path: str, query: str = "") -> Any: + """App API GET 请求(自动携带登录态 token)。""" + full = f"https://app.missevan.com{path}" + date = _utc_millis() + nonce = str(uuid.uuid4()) + token = get_me_token() + cookie = f"equip_id={EQUIP_ID}" + (f"; token={token}" if token else "") + resp = await http_fetch( + f"{full}?{query}" if query else full, + headers={ + "User-Agent": APP_UA, + "Accept": "application/json", + "Accept-Language": "zh-Hans-CN;q=1", + "x-m-date": date, + "x-m-nonce": nonce, + "Cookie": cookie, + "Authorization": build_auth("GET", full, query, equip_id=EQUIP_ID, token=token, date=date, nonce=nonce), + }, + timeout=15_000, + ) + if resp.status_code != 200: + raise RuntimeError(f"猫耳 App API HTTP {resp.status_code}") + body = as_dict(resp.body) + code = body.get("code") + if body.get("success") is False or (code is not None and code != 0): + raise RuntimeError(str(body.get("info") or f"猫耳 App API code {code}")) + info = body.get("info") + return info if info is not None else body + + +ME_UA = APP_UA diff --git a/app/sdk/platforms/mg/__init__.py b/app/sdk/platforms/mg/__init__.py new file mode 100644 index 0000000..ba46635 --- /dev/null +++ b/app/sdk/platforms/mg/__init__.py @@ -0,0 +1,80 @@ +"""咪咕音乐(对应 sdk/mg/index.ts 的 default export)。 + +能力位与 TS 一致:搜索 / 专辑 / 歌单 / 链接解析,顶层挂 get_music_url(取流在 apis/mg.py)、 +get_lyric、get_pic、get_music_info、get_music_detail_page_url。 + +TS 侧没有榜单、歌单广场、热搜、歌手、评论、个人歌单子模块,这里一律省略 —— +缺省即不支持,/sources 与路由据此返回 404。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms._base import Platform +from app.sdk.platforms.mg import album, lyric, music_search, parse, playlist +from app.sdk.platforms.mg.lyric import content_id_of +from app.sdk.platforms.mg.util import ( + fetch_song_detail, + first_non_empty, + migu_song_link, + normalize_migu_image_url, + pick_migu_image, +) + + +async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + """取流入口。延迟导入 apis.mg:apis 模块反向依赖 platforms.mg.util, + 模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块。""" + from app.sdk.apis import mg as mg_api + + return await mg_api.get_music_url(song_info, quality, retry_num) + + +async def get_pic(song_info: dict[str, Any]) -> str | None: + if song_info.get("img"): + return str(song_info["img"]) + content_id = content_id_of(song_info) + if not content_id: + return None + item = await fetch_song_detail(content_id) + return ( + normalize_migu_image_url( + first_non_empty( + pick_migu_image(item.get("imgItems")), + pick_migu_image(item.get("albumImgs")), + item.get("img1"), + item.get("img2"), + item.get("img3"), + ) + ) + or None + ) + + +async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]: + content_id = content_id_of(song_info) + if not content_id: + raise RuntimeError("migu 歌曲 id 无效") + return await fetch_song_detail(content_id) + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + extra = song_info.get("extra") or {} + link = str(extra.get("link") or "") if isinstance(extra, dict) else "" + return link or migu_song_link(content_id_of(song_info)) + + +MODULE = Platform( + id="mg", + name="咪咕音乐", + music_search=music_search, + 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, +) diff --git a/app/sdk/platforms/mg/album.py b/app/sdk/platforms/mg/album.py new file mode 100644 index 0000000..05fc3b0 --- /dev/null +++ b/app/sdk/platforms/mg/album.py @@ -0,0 +1,164 @@ +"""咪咕专辑(对应 sdk/mg/album.ts)。 + +搜索(search_all.do 的 album 开关)+ 曲目(queryAlbumSong,分页聚合去重)+ +详情(resourceinfo 2003 的元数据)。上游对应 music-lib/migu/album.go、 +migu.go 的 fetchAlbumDetail / fetchAlbumSongs。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.platforms.mg.music_search import search_all +from app.sdk.platforms.mg.util import ( + build_url, + convert_item_to_song, + first_non_empty, + migu_album_link, + migu_get, + pick_migu_image, + to_number, +) + +QUERY_ALBUM_SONG_URL = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong" +RESOURCE_INFO_URL = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do" + +_ALBUM_PATTERNS = ( + re.compile(r"music\.migu\.cn/(?:v3|v5)/music/album/(\d+)"), + re.compile(r"albumId=(\d+)"), + re.compile(r"resourceId=(\d+)"), +) + + +async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]: + body = await search_all(keyword, "album", page, limit) + data = body.get("albumResultData") if isinstance(body, dict) else None + result = (data or {}).get("result") or [] + album_list: list[dict[str, Any]] = [] + for item in result: + album_id = str(item.get("id") or "").strip() + if not album_id: + continue + album_list.append( + { + "id": album_id, + "name": str(item.get("name") or "").strip(), + "cover": pick_migu_image(item.get("imgItems")), + "creator": str(item.get("singer") or "").strip(), + "description": first_non_empty(item.get("desc"), item.get("publishDate")), + "link": migu_album_link(album_id), + "source": "mg", + } + ) + return {"list": album_list, "source": "mg", "page": page, "limit": limit, "total": len(album_list)} + + +async def fetch_album_songs(album_id: str) -> dict[str, Any]: + """专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs)。""" + album = str(album_id or "").strip() + if not album: + raise RuntimeError("migu 专辑 id 为空") + page_size = 50 + seen: set[str] = set() + songs: list[dict[str, Any]] = [] + total_count = 0 + + page_no = 1 + while True: + body = await migu_get( + build_url(QUERY_ALBUM_SONG_URL, {"albumId": album, "pageNo": page_no, "pageSize": page_size}) + ) + body = body if isinstance(body, dict) else {} + if body.get("code") and body["code"] != "000000": + raise RuntimeError(f"migu api error: {body.get('info')} (code {body.get('code')})") + data = body.get("data") or {} + song_list = data.get("songList") or [] + if not total_count: + total_count = int(to_number(data.get("totalCount"))) + if not song_list: + break + + before = len(songs) + for item in song_list: + song = convert_item_to_song(item, True) + if song is None: + continue + key = ( + first_non_empty( + song["extra"].get("content_id"), item.get("contentId"), song["songmid"], item.get("copyrightId") + ) + or f"{song['name']}|{song['singer']}" + ) + if key in seen: + continue + seen.add(key) + songs.append(song) + + if len(song_list) < page_size: + break + if total_count > 0 and len(songs) >= total_count: + break + if len(songs) == before: + break + page_no += 1 + + if not songs: + raise RuntimeError("migu 专辑没有可播放歌曲") + return {"songs": songs, "total": total_count or len(songs)} + + +async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]: + """专辑详情(曲目 + resourceinfo 2003 的元数据),返回形状对齐 kg/kw 的专辑详情。""" + album = str(album_id or "").strip() + if not album: + raise RuntimeError("migu 专辑 id 为空") + fetched = await fetch_album_songs(album) + songs: list[dict[str, Any]] = fetched["songs"] + total: int = fetched["total"] + + info: dict[str, str] = {"name": album, "img": "", "desc": "", "author": ""} + try: + body = await migu_get( + build_url(RESOURCE_INFO_URL, {"needSimple": "00", "resourceType": "2003", "resourceId": album}) + ) + body = body if isinstance(body, dict) else {} + if body.get("code") and body["code"] != "000000": + raise RuntimeError(str(body.get("info"))) + resource = body.get("resource") or [] + res = resource[0] if resource and isinstance(resource[0], dict) else None + if res: + info = { + "name": str(res.get("title") or "").strip() or album, + "img": pick_migu_image(res.get("imgItems")), + "desc": str(res.get("summary") or "").strip(), + "author": str(res.get("singer") or "").strip(), + } + if not info["img"] and songs[0].get("img"): + info["img"] = str(songs[0]["img"]) + except Exception: # noqa: BLE001 - 元数据是锦上添花,失败仍返回曲目 + if songs[0].get("img"): + info["img"] = str(songs[0]["img"]) + + start = (page - 1) * limit + return { + "source": "mg", + "info": info, + "list": songs[start : start + limit], + "page": page, + "limit": limit, + "total": total or len(songs), + } + + +async def get_album_songs(album_id: str) -> list[dict[str, Any]]: + return (await fetch_album_songs(album_id))["songs"] + + +async def parse_album_link(link: str) -> dict[str, Any]: + raw = str(link or "") + for pattern in _ALBUM_PATTERNS: + match = pattern.search(raw) + if match: + return await get_album_detail(match.group(1)) + raise RuntimeError("无效的咪咕专辑链接") diff --git a/app/sdk/platforms/mg/lyric.py b/app/sdk/platforms/mg/lyric.py new file mode 100644 index 0000000..5e2ad44 --- /dev/null +++ b/app/sdk/platforms/mg/lyric.py @@ -0,0 +1,49 @@ +"""咪咕歌词(对应 sdk/mg/lyric.ts)。 + +resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本;歌词 CDN 只认 y.migu.cn 的 Referer, +与资源接口的 music.migu.cn Referer 不同,所以单独发一次请求。 +上游对应 music-lib/migu/lyric.go。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.mg.util import SONG_DETAIL_URL, build_url, migu_get + +LRC_REFERER = "https://y.migu.cn/" +LRC_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36" +) + +_HTTP_PREFIX = re.compile(r"^http://") + + +def content_id_of(song_info: dict[str, Any]) -> str: + """从 extra.content_id 或 songmid(contentId|resourceType|formatType)取 contentId。""" + extra = song_info.get("extra") or {} + if isinstance(extra, dict) and extra.get("content_id"): + return str(extra["content_id"]) + raw_id = str(song_info.get("songmid") or song_info.get("id") or "") + return raw_id.split("|")[0] + + +async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]: + content_id = content_id_of(song_info) + if not content_id: + raise RuntimeError("migu 歌曲 id 无效") + body = await migu_get(build_url(SONG_DETAIL_URL, {"resourceId": content_id, "resourceType": "2"})) + resource = body.get("resource") if isinstance(body, dict) else None + if not resource: + raise RuntimeError("migu 歌词资源不存在") + first = resource[0] if isinstance(resource[0], dict) else {} + lrc_url = str(first.get("lrcUrl") or "") or str(first.get("lyricUrl") or "") + if not lrc_url: + return {"lyric": "", "tlyric": ""} + lrc_url = _HTTP_PREFIX.sub("https://", lrc_url) + resp = await http_fetch(lrc_url, headers={"User-Agent": LRC_UA, "Referer": LRC_REFERER}, timeout=20_000) + if resp.status_code != 200: + raise RuntimeError(f"migu 歌词下载失败 {resp.status_code}") + return {"lyric": resp.body if isinstance(resp.body, str) else "", "tlyric": ""} diff --git a/app/sdk/platforms/mg/music_search.py b/app/sdk/platforms/mg/music_search.py new file mode 100644 index 0000000..efbfe28 --- /dev/null +++ b/app/sdk/platforms/mg/music_search.py @@ -0,0 +1,60 @@ +"""咪咕音乐搜索(对应 sdk/mg/musicSearch.ts)。 + +search_all.do(Android_migu 参数),searchSwitch 控制返回类别 —— 歌曲/专辑/歌单三个 +子模块共用这一个接口,只切换 searchSwitch。 +上游对应 music-lib/migu/migu.go 的 (*Migu).Search。 +""" + +from __future__ import annotations + +import json +import math +from typing import Any + +from app.sdk.platforms.mg.util import build_url, convert_item_to_song, migu_get, parse_int + +SEARCH_ALL_URL = "http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do" + + +def search_switch(kind: str) -> str: + """搜索开关:只开某一类(song/album/songlist),bestShow 保留最优推荐。""" + payload = { + "song": 1 if kind == "song" else 0, + "album": 1 if kind == "album" else 0, + "singer": 0, + "tagSong": 0, + "mvSong": 0, + "songlist": 1 if kind == "songlist" else 0, + "bestShow": 1, + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + + +async def search_all(text: str, kind: str, page: int = 1, limit: int = 20) -> Any: + url = build_url( + SEARCH_ALL_URL, + { + "ua": "Android_migu", + "version": "5.0.1", + "text": text, + "pageNo": page, + "pageSize": limit, + "searchSwitch": search_switch(kind), + }, + ) + return await migu_get(url) + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + body = await search_all(keyword, "song", page, limit) + data = body.get("songResultData") if isinstance(body, dict) else None + result = (data or {}).get("result") or [] + songs = [song for item in result if (song := convert_item_to_song(item, True)) is not None] + total = parse_int((data or {}).get("totalCount")) or len(songs) + return { + "list": songs, + "allPage": max(1, math.ceil(total / limit)), + "total": total, + "limit": limit, + "source": "mg", + } diff --git a/app/sdk/platforms/mg/parse.py b/app/sdk/platforms/mg/parse.py new file mode 100644 index 0000000..b64200a --- /dev/null +++ b/app/sdk/platforms/mg/parse.py @@ -0,0 +1,44 @@ +"""咪咕链接解析(对应 sdk/mg/index.ts 的 parseSong)。 + +歌曲:`https://music.migu.cn/v3/music/song/` 或裸 contentId(6 位以上数字)。 +专辑/歌单分别复用 album / playlist 的链接解析。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.platforms.mg.album import parse_album_link +from app.sdk.platforms.mg.playlist import parse_playlist_link +from app.sdk.platforms.mg.util import convert_item_to_song, fetch_song_detail + +_SONG_PATTERNS = ( + re.compile(r"music\.migu\.cn/v3/music/song/(\d+)"), + re.compile(r"^(\d{6,})$"), +) + + +async def parse_song(link: str) -> dict[str, Any]: + raw = str(link or "") + content_id = "" + for pattern in _SONG_PATTERNS: + match = pattern.search(raw) + if match: + content_id = match.group(1) + break + if not content_id: + raise RuntimeError("无效的咪咕歌曲链接") + item = await fetch_song_detail(content_id) + song = convert_item_to_song(item, True) + if song is None: + raise RuntimeError("该歌曲没有可用格式") + return song + + +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) diff --git a/app/sdk/platforms/mg/playlist.py b/app/sdk/platforms/mg/playlist.py new file mode 100644 index 0000000..a3ce89b --- /dev/null +++ b/app/sdk/platforms/mg/playlist.py @@ -0,0 +1,187 @@ +"""咪咕歌单(对应 sdk/mg/playlist.ts)。 + +搜索(search_all.do 的 songlist 开关)、曲目(MIGUM3.0 playlist/song/v2.0)、 +分类(静态关键词表,上游同款,平台未提供分类接口)、链接解析。 +上游对应 music-lib/migu/playlist.go。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.platforms.mg.music_search import search_all +from app.sdk.platforms.mg.util import ( + build_url, + convert_item_to_song, + first_non_empty, + migu_get, + migu_playlist_link, + parse_int, + pick_migu_image, + to_number, +) + +PLAYLIST_SONG_URL = "https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0" + +_PLAYLIST_PATTERNS = ( + re.compile(r"playlistId=(\d+)"), + re.compile(r"musicListId=(\d+)"), + re.compile(r"(?:playlist|songlist)/(\d+)"), +) + +# 静态分类(上游 miguPlaylistCategoryKeywords) +CATEGORY_KEYWORDS: list[dict[str, Any]] = [ + {"name": "华语", "group": "语种", "hot": True}, + {"name": "欧美", "group": "语种", "hot": True}, + {"name": "日语", "group": "语种"}, + {"name": "韩语", "group": "语种"}, + {"name": "粤语", "group": "语种"}, + {"name": "流行", "group": "风格", "hot": True}, + {"name": "摇滚", "group": "风格", "hot": True}, + {"name": "民谣", "group": "风格", "hot": True}, + {"name": "电子", "group": "风格"}, + {"name": "说唱", "group": "风格"}, + {"name": "古风", "group": "风格"}, + {"name": "轻音乐", "group": "风格"}, + {"name": "影视", "group": "场景", "hot": True}, + {"name": "ACG", "group": "场景"}, + {"name": "治愈", "group": "场景"}, + {"name": "运动", "group": "场景"}, + {"name": "学习", "group": "场景"}, + {"name": "睡前", "group": "场景"}, +] + + +def _to_playlist(item: dict[str, Any], category_id: str | None = None) -> dict[str, Any] | None: + playlist_id = str(item.get("id") or "").strip() + name = str(item.get("name") or "").strip() + if not playlist_id or not name: + return None + entry: dict[str, Any] = { + "id": playlist_id, + "name": name, + "cover": first_non_empty(item.get("musicListPicUrl"), pick_migu_image(item.get("imgItems"))), + "trackCount": parse_int(item.get("musicNum")), + "playCount": parse_int(item.get("playNum")), + "creator": first_non_empty(item.get("userName"), item.get("ownerName")), + "link": migu_playlist_link(playlist_id), + "source": "mg", + } + if category_id: + entry["categoryId"] = category_id + return entry + + +async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌单搜索;分类歌单也用同一接口,关键词即分类名。""" + body = await search_all(keyword, "songlist", page, limit) + data = body.get("songListResultData") if isinstance(body, dict) else None + result = (data or {}).get("result") or [] + playlists = [entry for item in result if (entry := _to_playlist(item)) is not None] + return {"list": playlists, "source": "mg", "page": page, "limit": limit, "total": len(playlists)} + + +async def get_categories() -> dict[str, Any]: + return { + "source": "mg", + "list": [ + {"id": "", "name": "全部", "group": "全部"}, + *( + {"id": item["name"], "name": item["name"], "group": item["group"], "hot": bool(item.get("hot"))} + for item in CATEGORY_KEYWORDS + ), + ], + } + + +async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + keyword = str(category_id or "").strip() or "华语" + found = await search_playlist(keyword, page, limit) + playlists = [entry for item in found["list"] if (entry := _to_playlist(item, keyword)) is not None] + return {"list": playlists, "source": "mg", "page": page, "limit": limit} + + +async def fetch_playlist_songs(playlist_id: str) -> dict[str, Any]: + """歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs)。""" + playlist = str(playlist_id or "").strip() + if not playlist: + raise RuntimeError("migu 歌单 id 为空") + page_size = 50 + seen: set[str] = set() + songs: list[dict[str, Any]] = [] + total_count = 0 + + page_no = 1 + while True: + body = await migu_get( + build_url(PLAYLIST_SONG_URL, {"pageNo": page_no, "pageSize": page_size, "playlistId": playlist}) + ) + body = body if isinstance(body, dict) else {} + if body.get("code") and body["code"] != "000000": + raise RuntimeError(f"migu api error: {body.get('info')} (code {body.get('code')})") + data = body.get("data") or {} + song_list = data.get("songList") or [] + if not total_count: + total_count = int(to_number(data.get("totalCount"))) + if not song_list: + break + + before = len(songs) + for item in song_list: + song = convert_item_to_song(item, True) + if song is None: + continue + key = ( + first_non_empty( + song["extra"].get("content_id"), item.get("contentId"), song["songmid"], item.get("copyrightId") + ) + or f"{song['name']}|{song['singer']}" + ) + if key in seen: + continue + seen.add(key) + songs.append(song) + + if len(song_list) < page_size: + break + if total_count > 0 and len(songs) >= total_count: + break + if len(songs) == before: + break + page_no += 1 + + if not songs: + raise RuntimeError("migu 歌单没有可播放歌曲") + return {"songs": songs, "total": total_count or len(songs)} + + +async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]: + playlist = str(playlist_id or "").strip() + fetched = await fetch_playlist_songs(playlist) + songs: list[dict[str, Any]] = fetched["songs"] + total: int = fetched["total"] + start = (page - 1) * limit + return { + "source": "mg", + "info": {"name": playlist, "img": songs[0].get("img") or "", "desc": "", "author": ""}, + "list": songs[start : start + limit], + "page": page, + "limit": limit, + "total": total or len(songs), + } + + +async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]: + return (await fetch_playlist_songs(playlist_id))["songs"] + + +async def parse_playlist_link(link: str) -> dict[str, Any]: + raw = str(link or "") + for pattern in _PLAYLIST_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("无效的咪咕歌单链接") diff --git a/app/sdk/platforms/mg/util.py b/app/sdk/platforms/mg/util.py new file mode 100644 index 0000000..5090ffd --- /dev/null +++ b/app/sdk/platforms/mg/util.py @@ -0,0 +1,460 @@ +"""咪咕音乐公共层(对应 sdk/mg/util.ts)。 + +常量、请求封装、条目 → 歌曲的统一转换、档位/扩展名工具与 Z3D 解密。 +上游对应 music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等)。 + +匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie +(接口返回「登录后免费试听」)。 + +Z3D 解密与 `app/http/mg_stream.py` 的流式实现同源:那边服务 /stream/mg 代理, +这里供取流探测阶段校验候选是否真能解出音频,两处算法必须逐字一致。 +""" + +from __future__ import annotations + +import hashlib +import math +import re +from typing import Any +from urllib.parse import urlencode + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from app.sdk.auth_store import get_cookie +from app.sdk.common.request import http_fetch + +# ---------------- 常量 ---------------- + +#: 咪咕 toneFlag → 平台音质标签(types 展示与取流返回共用) +TONE_QUALITY: dict[str, str] = { + "LQ": "64k", + "PQ": "128k", + "HQ": "320k", + "SQ": "flac", + "ZQ": "flac24bit", + "ZQ24": "flac24bit", + "ZQ32": "wav", + "Z3D": "wav", + "I3D": "flac24bit", + "3D60": "wav", +} + +#: tone → 明文容器扩展名(取流侧判定档位与扩展名共用) +TONE_EXT: dict[str, str] = { + "LQ": "mp3", + "PQ": "mp3", + "HQ": "mp3", + "SQ": "flac", + "ZQ": "flac", + "ZQ24": "flac", + "ZQ32": "wav", + "Z3D": "wav", + "I3D": "m4a", + "3D60": "wav", +} + +USER_AGENT = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 " + "(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" +) +REFERER = "http://music.migu.cn/" + +#: listen-url 走 Android 客户端 UA/头,与网页通道不同 UA +ANDROID_UA = "Android_migu/7.41.13 okhttp/3.12.13" +ANDROID_CHANNEL = "0146832" +ANDROID_VERSION = "7.41.13" + +SONG_DETAIL_URL = "http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do" + +# Z3D 加密流的 app code 密文(AES-128-CBC,key 为 AppKey 前 16 字符) +_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)) + +_INT_PREFIX = re.compile(r"[+-]?\d+") + + +# ---------------- 请求 ---------------- + + +def get_migu_cookie() -> str: + return get_cookie("mg").strip() + + +async def migu_get(url: str, extra_headers: dict[str, str] | None = None) -> Any: + """带 Referer/UA/Cookie 的 GET,返回解析后的 body(JSON 已解析)。""" + headers: dict[str, str] = {"User-Agent": USER_AGENT, "Referer": REFERER, **(extra_headers or {})} + cookie = get_migu_cookie() + if cookie: + headers["Cookie"] = cookie + resp = await http_fetch(url, headers=headers, timeout=20_000) + if resp.status_code != 200: + raise RuntimeError(f"migu http {resp.status_code}") + return resp.body + + +def build_url(base: str, params: dict[str, Any]) -> str: + """URLSearchParams 语义:值统一 String 后 form-urlencoded。""" + return f"{base}?{urlencode([(key, str(value)) for key, value in params.items()])}" + + +# ---------------- 工具函数(1:1 对应上游) ---------------- + + +def parse_int(value: Any) -> int: + """JS parseInt 语义:取前导整数,解析不出返回 0。""" + match = _INT_PREFIX.match(str(value if value is not None else "").strip()) + return int(match.group()) if match else 0 + + +def to_number(value: Any) -> float: + """JS Number() 语义:不可解析或非有限值归零(分页 totalCount / 体积等字段用)。""" + try: + number = float(str(value if value is not None else "").strip()) + except (TypeError, ValueError): + return 0.0 + return number if math.isfinite(number) else 0.0 + + +def first_non_empty(*values: Any) -> str: + for value in values: + if value is None: + continue + if str(value).strip(): + return str(value) + return "" + + +def first_non_zero_string(*values: Any) -> str: + for value in values: + text = str(value if value is not None else "").strip() + if text and text != "0": + return text + return "" + + +def pick_migu_image(items: Any) -> str: + """按 02/01/03 的尺寸优先级取图,都没有则取第一张非空。""" + if not isinstance(items, list): + return "" + for preferred in ("02", "01", "03"): + for item in items: + entry = item if isinstance(item, dict) else {} + if entry.get("imgSizeType") == preferred and str(entry.get("img") or "").strip(): + return str(entry.get("img")) + for item in items: + entry = item if isinstance(item, dict) else {} + if str(entry.get("img") or "").strip(): + return str(entry.get("img")) + return "" + + +def normalize_migu_image_url(image: Any) -> str: + url = str(image if image is not None else "").strip() + if not url: + return "" + if url.startswith(("http://", "https://")): + return url + if url.startswith("//"): + return f"https:{url}" + if url.startswith("/"): + return f"https://d.musicapp.migu.cn{url}" + return url + + +def collect_migu_artist_names(item: dict[str, Any]) -> list[str]: + names: list[str] = [] + seen: set[str] = set() + + def append(name: Any) -> None: + text = str(name if name is not None else "").strip() + if not text or text in seen: + return + seen.add(text) + names.append(text) + + for group in ("singers", "singerList", "artists"): + for singer in item.get(group) or []: + append((singer or {}).get("name") if isinstance(singer, dict) else None) + if not names: + for name in str(item.get("singer") or "").split("|"): + append(name) + return names + + +_Z3D_CODE_KEYS = ( + "formatType", + "resourceType", + "size", + "androidSize", + "isize", + "asize", + "fileType", + "androidFileType", + "iformat", + "aformat", + "price", +) + + +def collect_migu_formats(item: dict[str, Any]) -> list[dict[str, Any]]: + """汇总三组 rateFormats + z3dCode,按 formatType|resourceType 去重。""" + groups: list[list[dict[str, Any]]] = [] + for key in ("newRateFormats", "rateFormats", "audioFormats"): + group = item.get(key) + if isinstance(group, list): + groups.append([entry for entry in group if isinstance(entry, dict)]) + code = item.get("z3dCode") + if isinstance(code, dict) and code.get("formatType"): + groups.append([{key: code.get(key) for key in _Z3D_CODE_KEYS}]) + + formats: list[dict[str, Any]] = [] + seen: set[str] = set() + for group in groups: + for fmt in group: + key = f"{str(fmt.get('formatType') or '').strip().upper()}|{str(fmt.get('resourceType') or '').strip()}" + if key == "|" or key in seen: + continue + seen.add(key) + formats.append(fmt) + return formats + + +_FORMAT_RANK: dict[str, int] = { + "ZQ32": 80, + "ZQ": 75, + "ZQ24": 75, + "SQ": 70, + "HQ": 60, + "I3D": 50, + "Z3D": 50, + "3D60": 40, + "PQ": 30, + "LQ": 20, +} + + +def migu_format_rank(format_type: Any = "") -> int: + return _FORMAT_RANK.get(str(format_type or "").strip().upper(), 10) + + +def migu_format_ext(format_type: Any = "", format_code: Any = "") -> str: + tone = str(format_type or "").strip().upper() + code = str(format_code or "").strip() + if tone in ("SQ", "ZQ", "ZQ24"): + return "flac" + if tone in ("ZQ32", "Z3D", "3D60"): + return "wav" + if tone == "I3D": + return "m4a" + if tone in ("PQ", "HQ", "LQ"): + return "mp3" + return "flac" if code.startswith("011") else "mp3" + + +def normalize_tone(value: Any = "") -> str: + """取流档位归一:去掉前导点、大写,只认 TONE_EXT 里已知的 toneFlag。""" + text = str(value if value is not None else "").strip() + if text.startswith("."): + text = text[1:] + text = text.upper() + return text if text in TONE_EXT else "" + + +def migu_album_link(album_id: str) -> str: + return f"https://music.migu.cn/v3/music/album/{album_id}" + + +def migu_playlist_link(playlist_id: str) -> str: + return f"https://music.migu.cn/v5/#/playlist?playlistId={playlist_id}&playlistType=ordinary" + + +def migu_song_link(content_id: str) -> str: + return f"https://music.migu.cn/v3/music/song/{content_id}" + + +def format_seconds(seconds: float) -> str: + minutes = math.floor(seconds / 60) + secs = math.floor(seconds % 60) + return f"{minutes:02d}:{secs:02d}" + + +# ---------------- 条目 → 歌曲(上游 convertItemToSongWithOption) ---------------- + + +def _seconds_from_size(size: int, bitrate: int) -> int: + return math.floor(size * 8 / bitrate) if bitrate > 0 else 0 + + +def convert_item_to_song(item: dict[str, Any], allow_paid: bool = True) -> dict[str, Any] | None: + artist_names = collect_migu_artist_names(item) + song_name = first_non_empty(item.get("name"), item.get("songName")) + album_name = str(item.get("album") or "").strip() + albums = item.get("albums") or [] + if isinstance(albums, list) and albums: + first_album = albums[0] if isinstance(albums[0], dict) else {} + if str(first_album.get("name") or "").strip(): + album_name = str(first_album.get("name")).strip() + + formats = collect_migu_formats(item) + if not formats: + return None + + duration = to_number(item.get("duration")) + download_tags = [str(tag or "").strip().lower() for tag in (item.get("downloadTags") or [])] + candidates: list[dict[str, Any]] = [] + + for index, fmt in enumerate(formats): + size_text = first_non_zero_string(fmt.get("androidSize"), fmt.get("asize"), fmt.get("size"), fmt.get("isize")) + size_value = parse_int(size_text) + ext = first_non_empty(fmt.get("androidFileType"), fmt.get("fileType")) + if not ext: + ext = migu_format_ext(fmt.get("formatType"), first_non_empty(fmt.get("aformat"), fmt.get("iformat"))) + + if not duration and size_value > 0: + bitrate = {"PQ": 128_000, "HQ": 320_000, "LQ": 64_000}.get(str(fmt.get("formatType") or ""), 0) + if bitrate > 0: + duration = _seconds_from_size(size_value, bitrate) + + price_value = parse_int(fmt.get("price")) + show_tags = fmt.get("showTag") + tags = show_tags if isinstance(show_tags, list) and show_tags else (fmt.get("showTags") or []) + is_vip_tag = any(str(tag or "").strip().lower() == "vip" for tag in tags) + if not is_vip_tag: + is_vip_tag = "vip" in download_tags + hidden_paid = item.get("chargeAuditions") == "1" and price_value >= 200 + + if allow_paid or (not is_vip_tag and not hidden_paid): + candidates.append( + { + "index": index, + "size": size_value, + "ext": ext, + "rank": migu_format_rank(fmt.get("formatType")), + "vip": is_vip_tag or hidden_paid, + "tone": str(fmt.get("formatType") or ""), + } + ) + + if not candidates: + return None + + candidates.sort(key=lambda entry: (-entry["rank"], -entry["size"])) + best = candidates[0] + best_format = formats[best["index"]] + + cover_url = normalize_migu_image_url( + first_non_empty( + pick_migu_image(item.get("imgItems")), + pick_migu_image(item.get("albumImgs")), + item.get("img1"), + item.get("img2"), + item.get("img3"), + ) + ) + # albums 数组是搜索响应的专辑来源(顶层 albumId 常为空),与上游 Extra 的 album_id 同义 + album_id = first_non_empty(item.get("albumId"), (albums[0] if albums else {}).get("id")) + link_id = first_non_empty(item.get("contentId"), item.get("copyrightId")) + + resource_type = best_format.get("resourceType") + format_type = best_format.get("formatType") + extra: dict[str, str] = { + "content_id": str(item.get("contentId") or ""), + # 注意:extra 用空串兜底,songmid 用 '2' 兜底(与原文两处写法不同) + "resource_type": str(resource_type if resource_type is not None else ""), + "format_type": str(format_type if format_type is not None else ""), + } + if item.get("copyrightId"): + extra["copyright_id"] = str(item["copyrightId"]) + if item.get("songId"): + extra["song_id"] = str(item["songId"]) + if album_id: + extra["album_id"] = album_id + + is_vip = item.get("chargeAuditions") == "1" or bool(best["vip"]) + songmid = f"{item.get('contentId') or ''}|{resource_type if resource_type is not None else '2'}|{format_type or ''}" + + # 各档位可用性(供前端展示);实际可取档位由取流接口的探测结果决定 + types = [{"type": TONE_QUALITY.get(entry["tone"], "128k"), "size": str(entry["size"])} for entry in candidates] + + return { + "songmid": songmid, + "name": song_name, + "singer": " / ".join(artist_names), + "albumId": album_id, + "albumName": album_name, + "interval": format_seconds(duration) if duration else "0", + "img": cover_url or None, + "lrc": None, + "types": types, + "_types": {}, + "payPlay": is_vip, + # 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里 + "extra": {**extra, "link": migu_song_link(link_id)}, + "source": "mg", + } + + +# ---------------- 歌曲详情 ---------------- + + +async def fetch_song_detail(content_id: str) -> dict[str, Any]: + url = build_url(SONG_DETAIL_URL, {"resourceType": "2", "resourceId": content_id}) + body = await migu_get(url) + resource = body.get("resource") if isinstance(body, dict) else None + if not resource: + raise RuntimeError("migu 歌曲详情为空") + return resource[0] + + +# ---------------- Z3D 解密(上游 DecryptAudio / miguZ3DStreamKey) ---------------- + + +def is_migu_audio_magic(data: bytes) -> bool: + """判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。""" + if not data or len(data) < 4: + return False + if data[:4] in (b"fLaC", b"OggS"): + return True + if data[:4] == 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: + """Z3D 流解密:逐字节 `plain[i] = cipher[i] - key[i % 32]`;已是明文则原样返回。""" + 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 diff --git a/app/sdk/platforms/qt/__init__.py b/app/sdk/platforms/qt/__init__.py new file mode 100644 index 0000000..8ff6e1b --- /dev/null +++ b/app/sdk/platforms/qt/__init__.py @@ -0,0 +1,65 @@ +"""蜻蜓FM 平台模块(对应 sdk/qt/index.ts)。 + +匿名可用面(2026-10-02 实测):搜索(官网 SSR 解析)/ 频道节目列表(SSR)/ +免费节目点播直链(audiostream 签名 302 到华为云 CDN)。 + +搜索结果为频道(专辑)级条目,songmid=channelId;节目条目 songmid=":"。 +付费节目与直播电台签名(Lwrpu$K5oP)暂未接登录。有声内容不参与聚合(noAggregate)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms._base import Platform +from app.sdk.platforms.qt import album, music_search + + +async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + from app.sdk.apis.qt import get_music_url + + return await get_music_url(song_info, quality, retry_num) + + +async def get_pic(song_info: dict[str, Any]) -> str | None: + """搜索结果已带封面;频道详情页无独立封面接口,降级用 SSR 页 og:image。""" + channel_id = str(song_info.get("songmid") or "").split(":")[0] + return await album.get_channel_og_image(channel_id) + + +async def get_album_detail(channel_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + del page, limit # SSR 页一次吐出全部节目,与上游一致不分页 + result = await album.get_channel_programs(channel_id) + songs = result["list"] + return { + "info": {"name": result["name"], "total": len(songs)}, + "list": songs, + "total": len(songs), + "limit": len(songs), + "allPage": 1, + "source": "qt", + } + + +class _QtAlbum: + get_album_detail = staticmethod(get_album_detail) + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + parts = str(song_info.get("songmid") or "").split(":") + channel_id = parts[0] if parts else "" + program_id = parts[1] if len(parts) > 1 else "" + if program_id: + return f"https://www.qingting.fm/channels/{channel_id}/programs/{program_id}" + return f"https://www.qingting.fm/channels/{channel_id}" + + +MODULE = Platform( + id="qt", + name="蜻蜓FM", + music_search=music_search, + album=_QtAlbum, + get_music_url=_get_music_url, + get_pic=get_pic, + get_music_detail_page_url=get_music_detail_page_url, +) diff --git a/app/sdk/platforms/qt/album.py b/app/sdk/platforms/qt/album.py new file mode 100644 index 0000000..0d4253c --- /dev/null +++ b/app/sdk/platforms/qt/album.py @@ -0,0 +1,68 @@ +"""蜻蜓FM 频道节目列表(对应 sdk/qt/album.ts,官网 SSR 页解析)。 + +接口: https://www.qingting.fm/channels/(302 → www.qtfm.cn,见 http.py) +实测(2026-10-02)SSR 输出含节目表格:a.pTitle href=/channels//programs/ + p[title] + span.col2 时长 +节目条目的 songmid 采用复合 id ``:``(取流签名需要两者)。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.platforms.qt.http import ssr_get + + +def _decode_entities(text: str) -> str: + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace(""", '"') + .replace("'", "'") + ) + + +async def _fetch_channel_html(channel_id: str) -> str: + result = await ssr_get(f"https://www.qingting.fm/channels/{channel_id}") + if result["statusCode"] != 200: + raise RuntimeError(f"获取频道失败(HTTP {result['statusCode']})") + return str(result["html"]) + + +async def get_channel_programs(channel_id: str) -> dict[str, Any]: + html = await _fetch_channel_html(channel_id) + + title_match = re.search(r"([^<]*)<", html) + raw_name = title_match.group(1).split("-")[0].strip() if title_match else "" + name = _decode_entities(raw_name or str(channel_id)) + + songs = [] + for match in re.finditer(r'href="/channels/(\d+)/programs/(\d+)"[^>]*>\s*<p[^>]*title="([^"]*)"', html): + songs.append( + { + "songmid": f"{match.group(1)}:{match.group(2)}", + "name": _decode_entities(match.group(3)) or f"节目 {match.group(2)}", + "singer": name, + "albumId": str(channel_id), + "albumName": name, + "interval": 0, + "payPlay": False, + "img": None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + return {"name": name, "list": songs} + + +async def get_channel_og_image(channel_id: str) -> str | None: + """频道封面(SSR 页 og:image)。""" + html = await _fetch_channel_html(channel_id) + match = re.search(r'property="og:image"\s+content="([^"]+)"', html) or re.search( + r'content="([^"]+)"\s+property="og:image"', html + ) + return match.group(1).replace("http://", "https://") if match else None diff --git a/app/sdk/platforms/qt/http.py b/app/sdk/platforms/qt/http.py new file mode 100644 index 0000000..d21cb96 --- /dev/null +++ b/app/sdk/platforms/qt/http.py @@ -0,0 +1,44 @@ +"""蜻蜓FM SSR 请求助手(对应 sdk/qt/http.ts)。 + +域名迁移(www.qingting.fm → www.qtfm.cn)伴随华为 WAF 会话: +302 时需带上 Set-Cookie 的 HWWAFSESID/HWWAFSESTIME 再请求 Location,否则可能被 WAF 拦。 +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urljoin + +from app.sdk.common.request import http_fetch + +UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" + + +async def ssr_get(url: str) -> dict[str, Any]: + """GET 并跟随最多 3 跳重定向(累计收集 Cookie 回带),返回最终响应 ``{statusCode, html}``。""" + current = url + cookie = "" + for _hop in range(4): + headers: dict[str, str] = { + "User-Agent": UA, + "Referer": "https://www.qtfm.cn/", + "Accept": "text/html,application/xhtml+xml", + } + if cookie: + headers["Cookie"] = cookie + resp = await http_fetch(current, headers=headers, timeout=15_000) + pairs = [item.split(";")[0].strip() for item in resp.set_cookies] + pairs = [pair for pair in pairs if pair] + if pairs: + cookie = "; ".join([part for part in cookie.split("; ") if part] + pairs) + + if 300 <= resp.status_code < 400: + location = str(resp.headers.get("location") or "") + if location: + current = location if location.startswith("http") else urljoin(current, location) + continue + return { + "statusCode": resp.status_code, + "html": resp.raw.decode("utf-8", errors="replace") if resp.raw else str(resp.body or ""), + } + raise RuntimeError("重定向次数过多") diff --git a/app/sdk/platforms/qt/music_search.py b/app/sdk/platforms/qt/music_search.py new file mode 100644 index 0000000..6325858 --- /dev/null +++ b/app/sdk/platforms/qt/music_search.py @@ -0,0 +1,54 @@ +"""蜻蜓FM 搜索(对应 sdk/qt/musicSearch.ts,官网 SSR 频道结果页解析)。 + +接口: https://www.qingting.fm/search/channel_ondemand/<keyword>(302 → www.qtfm.cn,见 http.py) +实测(2026-10-02)SSR 渲染且与关键词强相关;?keyword= 混合页的结果区为客户端渲染 +(服务端只吐热门推荐),JSON 搜索端点(webapi/webbff/searchapi)均 404——本页是唯一匿名搜索通道。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.platforms.fivesing.util import encode_uri_component +from app.sdk.platforms.qt.http import ssr_get + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + del retry_num # 契约参数,SSR 搜索无重试语义 + result = await ssr_get( + f"https://www.qingting.fm/search/channel_ondemand/{encode_uri_component(keyword)}?page={page}" + ) + if result["statusCode"] != 200: + raise RuntimeError(f"搜索失败(HTTP {result['statusCode']})") + html = str(result["html"]) + + # 结果项:href=/channels/<id> … <span title="<名称>">,封面在随后的 img src + items = [] + for match in re.finditer(r'href="/channels/(\d+)"[\s\S]{0,400}?<span title="([^"]*)">', html): + after = html[match.start() : match.start() + 700] + img = re.search(r'<img src="(http[^"]+)"', after) + items.append( + { + "songmid": match.group(1), + "name": match.group(2), + "singer": "", + "albumId": "", + "albumName": "", + "interval": 0, + "img": img.group(1).replace("http://", "https://") if img else None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + songs = [item for item in items if item["name"]][:limit] + return { + "allPage": page + (1 if len(songs) >= 10 else 0), + "total": len(songs), + "limit": limit, + "source": "qt", + "list": songs, + } diff --git a/app/sdk/platforms/tx/__init__.py b/app/sdk/platforms/tx/__init__.py new file mode 100644 index 0000000..fabc42a --- /dev/null +++ b/app/sdk/platforms/tx/__init__.py @@ -0,0 +1,62 @@ +"""QQ音乐(对应 sdk/tx/index.ts 的 default export)。 + +能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析 / 个人歌单, +顶层挂 get_music_url(取流在 apis/tx.py)、get_lyric、get_pic、get_music_info / +handle_music_info(songmid → SongInfo,供酷我兜底与评论/歌词反查数字 songId)、 +get_music_detail_page_url。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms._base import Platform +from app.sdk.platforms.tx import ( + album, + comment, + hot_search, + leaderboard, + lyric, + music_info, + music_search, + parse, + pic, + playlist, + singer, + song_list, + user_playlist, +) + + +async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + """取流入口。延迟导入 apis.tx:apis 模块反向依赖 platforms.tx.utils, + 模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块(wy 已踩过)。""" + from app.sdk.apis import tx as tx_api + + return await tx_api.get_music_url(song_info, quality, retry_num) + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + return f"https://y.qq.com/n/yqq/song/{song_info.get('songmid')}.html" + + +MODULE = Platform( + id="tx", + name="QQ音乐", + music_search=music_search, + leaderboard=leaderboard, + song_list=song_list, + hot_search=hot_search, + comment=comment, + singer=singer, + album=album, + playlist=playlist, + parse=parse, + user_playlist=user_playlist, + get_music_url=get_music_url, + get_lyric=lyric.get_lyric, + get_pic=pic.get_pic, + get_music_info=music_info.get_music_info, + handle_music_info=music_info.handle_music_info, + get_music_detail_page_url=get_music_detail_page_url, +) diff --git a/app/sdk/platforms/tx/album.py b/app/sdk/platforms/tx/album.py new file mode 100644 index 0000000..90ef5fc --- /dev/null +++ b/app/sdk/platforms/tx/album.py @@ -0,0 +1,183 @@ +"""QQ音乐专辑(移植自 sdk/tx/album.ts)。 + +三条路径: + * 详情 —— music.musichallAlbum.AlbumSongList/GetAlbumSongList(参数名必须是驼峰 + `albumMid`,下划线形态会被上游以 104400 拒绝) + * 全量曲目 —— 同一接口按 100 一批翻页(上游 fetchAlbumDetail 的曲目段) + * 搜索 —— c.y.qq.com 的 search_for_qq_cp `t=8` + +GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成, +封面走平台固定图片 URL 规则。 +""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.tx.filter import album_img, music_info_item +from app.sdk.platforms.tx.utils import create_musicu_fetch + +_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +ALBUM_BATCH = 100 + +SEARCH_URL = "http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p={page}&n={limit}&w={query}&t=8" + +_ALBUM_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"albumDetail/([A-Za-z0-9]+)"), + re.compile(r"/album/([A-Za-z0-9]+)"), + re.compile(r"albummid=([A-Za-z0-9]+)"), +) + + +def filter_song_list(raw: Any) -> list[dict[str, Any]]: + """GetAlbumSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。""" + return [music_info_item((item or {}).get("songInfo")) for item in raw or []] + + +def album_link(mid: str) -> str: + return f"https://y.qq.com/n/ryqq/albumDetail/{mid}" + + +async def fetch_album_songs(album_id: str) -> dict[str, Any]: + """专辑曲目全量(GetAlbumSongList 100 一批翻页)。 + + 终止条件与原文一致:本批为空、本批不足一批、或已翻到 totalNum 为止。 + """ + album_mid = str(album_id or "").strip() + if not album_mid: + raise RuntimeError("tx 专辑 id 为空") + + songs: list[dict[str, Any]] = [] + total_num = 0 + begin = 0 + while True: + body = await create_musicu_fetch( + { + "req": { + "module": "music.musichallAlbum.AlbumSongList", + "method": "GetAlbumSongList", + "param": {"albumMid": album_mid, "begin": begin, "num": ALBUM_BATCH, "order": 2}, + } + } + ) + req = body.get("req") or {} + if req.get("code") != 0: + raise RuntimeError("get album song list faild.") + data = req.get("data") or {} + batch = data.get("songList") or [] + if not total_num: + total_num = int(data.get("totalNum") or 0) + if not batch: + break + songs.extend(batch) + if len(batch) < ALBUM_BATCH: + break + if total_num > 0 and begin + len(batch) >= total_num: + break + begin += ALBUM_BATCH + + first: dict[str, Any] = {} + if songs: + first = (songs[0] or {}).get("songInfo") or {} + return { + "raw": songs, + "songs": filter_song_list(songs), + "total": total_num or len(songs), + "albumMid": album_mid, + "albumName": (first.get("album") or {}).get("name"), + } + + +async def get_album_detail(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]: + """专辑详情 + 曲目(路由优先走这个签名:get_album_detail(id, page, limit))。""" + body = await create_musicu_fetch( + { + "req": { + "module": "music.musichallAlbum.AlbumSongList", + "method": "GetAlbumSongList", + "param": {"albumMid": id, "begin": (page - 1) * limit, "num": limit}, + } + } + ) + req = body.get("req") or {} + if req.get("code") != 0: + raise RuntimeError("get album song list faild.") + + data = req.get("data") or {} + song_list = data.get("songList") or [] + first: dict[str, Any] = {} + if song_list: + first = (song_list[0] or {}).get("songInfo") or {} + authors: list[str] = [] + for item in song_list: + for singer in ((item or {}).get("songInfo") or {}).get("singer") or []: + name = (singer or {}).get("name") + if name and name not in authors: + authors.append(name) + return { + "source": "tx", + "info": { + "name": (first.get("album") or {}).get("name"), + "img": album_img(str(id)), + "desc": None, + "author": "、".join(authors) or None, + }, + "list": filter_song_list(song_list), + "page": page, + "limit": limit, + "total": data.get("totalNum"), + } + + +async def get_album_songs(id: str) -> list[dict[str, Any]]: + return (await fetch_album_songs(id))["songs"] + + +async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]: + """专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8)。""" + resp = await http_fetch( + SEARCH_URL.format(page=page, limit=limit, query=quote(str(keyword or ""), safe="")), + headers={"User-Agent": _UA, "Referer": "https://y.qq.com/portal/search.html"}, + timeout=15000, + ) + body = resp.body if isinstance(resp.body, dict) else {} + raw_data = body.get("data") + album_data: dict[str, Any] = (raw_data.get("album") if isinstance(raw_data, dict) else None) or {} + if resp.status_code != 200 or not album_data: + raise RuntimeError("tx 专辑搜索失败") + + rows: list[dict[str, Any]] = [] + for item in album_data.get("list") or []: + mid = str((item or {}).get("albumMID") or "").strip() + if not mid: + continue + rows.append( + { + "id": mid, + "name": str(item.get("albumName") or "").strip(), + "cover": f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{mid}.jpg", + "trackCount": int(item.get("song_count") or item.get("songCount") or 0), + "creator": str(item.get("singerName") or "").strip(), + "description": "", + "link": album_link(mid), + "type": "album", + "albumId": str(item.get("albumID") or ""), + "publishTime": str(item.get("publicTime") or ""), + "source": "tx", + } + ) + total = int(album_data.get("totalnum") or album_data.get("total") or len(rows)) or len(rows) + return {"list": rows, "page": page, "limit": limit, "total": total, "source": "tx"} + + +async def parse_album_link(link: str) -> dict[str, Any]: + """专辑链接解析(上游 ParseAlbum):命中任一形态即取详情,否则原文案报错。""" + raw = str(link or "") + for pattern in _ALBUM_PATTERNS: + match = pattern.search(raw) + if match: + return await get_album_detail(match.group(1)) + raise RuntimeError("无效的QQ音乐专辑链接") diff --git a/app/sdk/platforms/tx/comment.py b/app/sdk/platforms/tx/comment.py new file mode 100644 index 0000000..1b51c24 --- /dev/null +++ b/app/sdk/platforms/tx/comment.py @@ -0,0 +1,341 @@ +"""QQ音乐评论(移植自 sdk/tx/comment.ts)。 + +两路数据源(与原文一致,接口不同、返回形状不同): + * 最新评论 —— c.y.qq.com 的 fcg_global_comment_h5(form 表单,cmd=8) + * 热门评论 —— musicu.fcg 的 music.globalComment.CommentRead/GetHotCommentList + +原文靠 `_requestObj.cancelHttp()` 做并发去重;Python 请求层没有取消句柄(见 +common/request.py 的说明),这里改用 asyncio.Task 合并同一 songmid 的并发查询。 + +两处刻意的降级/容错: + * `middlecommentcontent` 的父评论头像与点赞数会被搬到第一条楼中楼,父条目置 null, + 随后楼中楼整体反转 —— 照抄原文,否则前端展示顺序与字段归属会和 TS 版不一致; + * 原文 `c.replynick.substring(1)` 在 replynick 缺失时会抛 TypeError 把整页打挂, + 这里按空串处理(唯一的偏离,只影响脏数据)。 +""" + +from __future__ import annotations + +import asyncio +import math +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import date_format2 +from app.sdk.platforms.tx.music_info import get_music_info_by_mid +from app.sdk.platforms.tx.utils import MUSICU_URL + +EMOJIS: dict[str, str] = { + "e400846": "😘", + "e400874": "😴", + "e400825": "😃", + "e400847": "😙", + "e400835": "😍", + "e400873": "😳", + "e400836": "😎", + "e400867": "😭", + "e400832": "😊", + "e400837": "😏", + "e400875": "😫", + "e400831": "😉", + "e400855": "😡", + "e400823": "😄", + "e400862": "😨", + "e400844": "😖", + "e400841": "😓", + "e400830": "😈", + "e400828": "😆", + "e400833": "😋", + "e400822": "😀", + "e400843": "😕", + "e400829": "😇", + "e400824": "😂", + "e400834": "😌", + "e400877": "😷", + "e400132": "🍉", + "e400181": "🍺", + "e401067": "☕️", + "e400186": "🥧", + "e400343": "🐷", + "e400116": "🌹", + "e400126": "🍃", + "e400613": "💋", + "e401236": "❤️", + "e400622": "💔", + "e400637": "💣", + "e400643": "💩", + "e400773": "🔪", + "e400102": "🌛", + "e401328": "🌞", + "e400420": "👏", + "e400914": "🙌", + "e400408": "👍", + "e400414": "👎", + "e401121": "✋", + "e400396": "👋", + "e400384": "👉", + "e401115": "✊", + "e400402": "👌", + "e400905": "🙈", + "e400906": "🙉", + "e400907": "🙊", + "e400562": "👻", + "e400932": "🙏", + "e400644": "💪", + "e400611": "💉", + "e400185": "🎁", + "e400655": "💰", + "e400325": "🐥", + "e400612": "💊", + "e400198": "🎉", + "e401685": "⚡️", + "e400631": "💝", + "e400768": "🔥", + "e400432": "👑", +} + +_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" +_UA_HOT = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.0.0" +) + +_NEW_COMMENT_URL = "http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg" +_EMOJI_RE = re.compile(r"\[em\](e\d+)\[/em\]") + +# songmid → songId,以及并发查询合并用的 task 表 +_song_id_map: dict[str, Any] = {} +_promises: dict[str, asyncio.Task[Any]] = {} + + +def replace_emoji(msg: str) -> str: + """`[em]e400846[/em]` → emoji;未知编号替换为空串(与原文 `|| ''` 一致)。""" + if not msg or "[em]" not in msg: + return msg + return _EMOJI_RE.sub(lambda match: EMOJIS.get(match.group(1), ""), msg) + + +def format_time(time: Any) -> int | None: + """原文字符串长度 < 10 视为无效时间返回 null;否则补 `000` 转毫秒。""" + text = str(time) + if len(text) < 10: + return None + try: + return int(text + "000") + except ValueError: + return None + + +async def get_song_id(song_info: dict[str, Any]) -> Any: + """songId 优先;否则 songmid 反查(带缓存 + 并发合并)。""" + song_id = song_info.get("songId") + if song_id: + return song_id + songmid = str(song_info.get("songmid") or "") + if songmid in _song_id_map: + return _song_id_map[songmid] + + task = _promises.get(songmid) + if task is None: + task = asyncio.ensure_future(get_music_info_by_mid(songmid)) + _promises[songmid] = task + try: + info = await asyncio.shield(task) + finally: + _promises.pop(songmid, None) + if not info: + raise RuntimeError("获取歌曲信息失败") + _song_id_map[songmid] = info.get("songId") + return info.get("songId") + + +def _time_pair(time: Any) -> tuple[int | None, str | None]: + parsed = format_time(time) + return parsed, (date_format2(parsed) if parsed else None) + + +def filter_new_comment(raw_list: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in raw_list or []: + if not isinstance(item, dict): + continue + time, time_str = _time_pair(item.get("time")) + middle = item.get("middlecommentcontent") + if middle: + # 父条目头像/点赞搬到第一条楼中楼,父条目置空,楼中楼反转(照抄原文) + first = middle[0] + first["avatarurl"] = item.get("avatarurl") + first["praisenum"] = item.get("praisenum") + item["avatarurl"] = None + item["praisenum"] = None + middle.reverse() + + replies: list[dict[str, Any]] = [] + for reply in middle or []: + if not isinstance(reply, dict): + continue + reply_time = time if reply.get("subcommentid") == item.get("commentid") else None + reply_time_str = time_str if reply.get("subcommentid") == item.get("commentid") else None + replies.append( + { + "id": f"sub_{item.get('rootcommentid')}_{reply.get('subcommentid')}", + "text": replace_emoji(str(reply.get("subcommentcontent") or "")).replace("\\n", "\n"), + "time": reply_time, + "timeStr": reply_time_str, + "userName": str(reply.get("replynick") or "")[1:], + "avatar": reply.get("avatarurl"), + "userId": reply.get("encrypt_replyuin"), + "likedCount": reply.get("praisenum"), + } + ) + + same_root = item.get("rootcommentid") == item.get("commentid") + rows.append( + { + "id": f"{item.get('rootcommentid')}_{item.get('commentid')}", + "rootId": item.get("rootcommentid"), + "text": replace_emoji(str(item.get("rootcommentcontent") or "")).replace("\\n", "\n") + if item.get("rootcommentcontent") + else "", + "time": time if same_root else None, + "timeStr": time_str if same_root else None, + "userName": str(item.get("rootcommentnick") or "")[1:], + "avatar": item.get("avatarurl"), + "userId": item.get("encrypt_rootcommentuin"), + "likedCount": item.get("praisenum"), + "reply": replies, + } + ) + return rows + + +def filter_hot_comment(raw_list: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in raw_list or []: + if not isinstance(item, dict): + continue + time, time_str = _time_pair(item.get("PubTime")) if item.get("PubTime") else (None, None) + replies: list[dict[str, Any]] = [] + for reply in item.get("SubComments") or []: + if not isinstance(reply, dict): + continue + reply_time, reply_time_str = _time_pair(reply.get("PubTime")) if reply.get("PubTime") else (None, None) + replies.append( + { + "id": f"sub_{reply.get('SeqNo')}_{reply.get('CmId')}", + "text": replace_emoji(str(reply.get("Content") or "")).replace("\\n", "\n"), + "time": reply_time, + "timeStr": reply_time_str, + "userName": reply.get("Nick") or "", + "avatar": reply.get("Avatar"), + "images": [reply["Pic"]] if reply.get("Pic") else [], + "userId": reply.get("EncryptUin"), + "likedCount": reply.get("PraiseNum"), + } + ) + rows.append( + { + "id": f"{item.get('SeqNo')}_{item.get('CmId')}", + "rootId": item.get("SeqNo"), + "text": replace_emoji(str(item.get("Content") or "")).replace("\\n", "\n") + if item.get("Content") + else "", + "time": time, + "timeStr": time_str, + "userName": item.get("Nick") or "", + "images": [item["Pic"]] if item.get("Pic") else [], + "avatar": item.get("Avatar"), + "location": item.get("Location") or "", + "userId": item.get("EncryptUin"), + "likedCount": item.get("PraiseNum"), + "reply": replies, + } + ) + return rows + + +async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]: + """最新评论(fcg_global_comment_h5,form 表单)。失败文案与原文一致:`获取评论失败`。""" + song_id = await get_song_id(song_info) + resp = await http_fetch( + _NEW_COMMENT_URL, + method="POST", + headers={"User-Agent": _UA_IE}, + form={ + "uin": "0", + "format": "json", + "cid": "205360772", + "reqtype": "2", + "biztype": "1", + "topid": song_id, + "cmd": "8", + "needmusiccrit": "1", + "pagenum": page - 1, + "pagesize": limit, + }, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 0: + raise RuntimeError("获取评论失败") + comment = body.get("comment") or {} + total = comment.get("commenttotal") or 0 + return { + "source": "tx", + "comments": filter_new_comment(comment.get("commentlist")), + "total": total, + "page": page, + "limit": limit, + "maxPage": math.ceil(total / limit) if limit else 1, + } + + +async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]: + """热门评论(musicu.fcg GetHotCommentList)。失败文案:`获取热门评论失败`。""" + song_id = await get_song_id(song_info) + resp = await http_fetch( + MUSICU_URL, + method="POST", + body={ + "comm": { + "cv": 4747474, + "ct": 24, + "format": "json", + "inCharset": "utf-8", + "outCharset": "utf-8", + "notice": 0, + "platform": "yqq.json", + "needNewCode": 1, + "uin": 0, + }, + "req": { + "module": "music.globalComment.CommentRead", + "method": "GetHotCommentList", + "param": { + "BizType": 1, + "BizId": str(song_id), + "LastCommentSeqNo": "", + "PageSize": limit, + "PageNum": page - 1, + "HotType": 1, + "WithAirborne": 0, + "PicEnable": 1, + }, + }, + }, + headers={"User-Agent": _UA_HOT, "referer": "https://y.qq.com/", "origin": "https://y.qq.com"}, + ) + 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: + raise RuntimeError("获取热门评论失败") + comment = (req.get("data") or {}).get("CommentList") or {} + total = comment.get("Total") or 0 + return { + "source": "tx", + "comments": filter_hot_comment(comment.get("Comments")), + "total": total, + "page": page, + "limit": limit, + "maxPage": math.ceil(total / limit) if limit else 1, + } diff --git a/app/sdk/platforms/tx/filter.py b/app/sdk/platforms/tx/filter.py new file mode 100644 index 0000000..fa604a1 --- /dev/null +++ b/app/sdk/platforms/tx/filter.py @@ -0,0 +1,80 @@ +"""QQ音乐 SongInfo 组装公共片段。 + +原 TS 把这套「file.size_* → types/_types」与「专辑图/歌手图」逻辑在 +musicSearch / songList / leaderboard / singer / parse 里各抄了一份。迁移时合并到一处, +字段与判定顺序保持原样(顺序影响前端音质下拉的排列)。 +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from app.sdk.common.utils import format_play_time, format_singer_name, size_formate + +# 顺序即原实现的 push 顺序:(file 字段, 对外音质名) +_SIZE_FIELDS: tuple[tuple[str, str], ...] = ( + ("size_128mp3", "128k"), + ("size_320mp3", "320k"), + ("size_flac", "flac"), + ("size_hires", "flac24bit"), +) + + +def build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + """file 体积字段 → (types, _types)。0/缺失表示该档不存在(与原文 `!== 0` 一致)。""" + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for key, quality in _SIZE_FIELDS: + if file.get(key): + size = size_formate(file[key]) + types.append({"type": quality, "size": size}) + _types[quality] = {"size": size} + return types, _types + + +def song_img(album_mid: Any, singers: Any) -> str: + """有专辑 mid 用专辑图(T002),否则退回首位歌手头像(T001)。""" + if album_mid in ("", "空", None): + if isinstance(singers, list) and singers and isinstance(singers[0], dict) and singers[0].get("mid"): + return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg" + return "" + return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg" + + +def album_img(album_mid: Any) -> str: + """专辑封面(singer 专辑列表、专辑搜索用,不退化到歌手图)。""" + return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg" + + +def music_info_item(item: Mapping[str, Any] | None) -> dict[str, Any]: + """原始曲目条目 → SongInfo(对应 singer.ts 的 filterMusicInfoItem)。 + + 比 music_search/filter_list 的版本多带 lrc/otherSource 两个空字段 —— 原文 + 只在 filterMusicInfoItem 里带,专辑/歌手曲目沿用这个形状,不要合并掉。 + """ + raw: Mapping[str, Any] = item or {} + file = raw.get("file") or {} + types, _types = build_types(file) + album = raw.get("album") or {} + album_name = album.get("name") or "" + album_mid = album.get("mid") or "" + singers = raw.get("singer") or [] + return { + "source": "tx", + "singer": format_singer_name(singers, "name"), + "name": raw.get("title"), + "albumName": album_name, + "albumId": album_mid, + "albumMid": album_mid, + "interval": format_play_time(raw.get("interval")), + "songId": raw.get("id"), + "songmid": raw.get("mid"), + "strMediaMid": file.get("media_mid"), + "img": song_img("" if album_name in ("", "空") else album_mid, singers), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } diff --git a/app/sdk/platforms/tx/hot_search.py b/app/sdk/platforms/tx/hot_search.py new file mode 100644 index 0000000..a6f6dd7 --- /dev/null +++ b/app/sdk/platforms/tx/hot_search.py @@ -0,0 +1,52 @@ +"""QQ音乐热搜(移植自 sdk/tx/hotSearch.ts)。 + +musicu.fcg 的 tencent_musicsoso_hotkey.HotkeyService/GetHotkeyForQQMusicPC。 +失败文案与原文一致:`获取热搜词失败`;重试上限 3 次(try max num)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.tx.utils import MUSICU_URL + + +def filter_list(raw_list: Any) -> list[str]: + return [str(item.get("query")) for item in raw_list or [] if isinstance(item, dict) and item.get("query")] + + +async def get_list(retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + + resp = await http_fetch( + MUSICU_URL, + method="post", + headers={"Referer": "https://y.qq.com/portal/player.html"}, + body={ + "comm": { + "ct": "19", + "cv": "1803", + "guid": "0", + "patch": "118", + "psrf_access_token_expiresAt": 0, + "psrf_qqaccess_token": "", + "psrf_qqopenid": "", + "psrf_qqunionid": "", + "tmeAppID": "qqmusic", + "tmeLoginType": 0, + "uin": "0", + "wid": "0", + }, + "hotkey": { + "method": "GetHotkeyForQQMusicPC", + "module": "tencent_musicsoso_hotkey.HotkeyService", + "param": {"search_id": "", "uin": 0}, + }, + }, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 0: + raise RuntimeError("获取热搜词失败") + return {"source": "tx", "list": filter_list(((body.get("hotkey") or {}).get("data") or {}).get("vec_hotkey"))} diff --git a/app/sdk/platforms/tx/leaderboard.py b/app/sdk/platforms/tx/leaderboard.py new file mode 100644 index 0000000..0ca79a6 --- /dev/null +++ b/app/sdk/platforms/tx/leaderboard.py @@ -0,0 +1,150 @@ +"""QQ音乐榜单(移植自 sdk/tx/leaderboard.ts)。 + +榜单列表是硬编码的 25 个(boardList);详情走 musicu.fcg 的 +musicToplist.ToplistInfoServer/GetDetail,period 参数(如 2026_31)从 top.html +按正则抓取并按 bangid 缓存。抓不到就传空串 —— 接口对缺省 period 容忍, +与原文 `info ? info.period : getPeriods(bangid)` 的降级一致。 + +上游不分页:一次返回 LIMIT 首,page 入参仅为对齐协议。 +""" + +from __future__ import annotations + +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_time, format_singer_name +from app.sdk.platforms.tx.filter import build_types, song_img +from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU + +LIMIT = 300 + +BOARD_LIST: list[dict[str, str]] = [ + {"id": "tx__4", "name": "流行指数榜", "bangid": "4"}, + {"id": "tx__26", "name": "热歌榜", "bangid": "26"}, + {"id": "tx__27", "name": "新歌榜", "bangid": "27"}, + {"id": "tx__62", "name": "飙升榜", "bangid": "62"}, + {"id": "tx__58", "name": "说唱榜", "bangid": "58"}, + {"id": "tx__57", "name": "喜力电音榜", "bangid": "57"}, + {"id": "tx__28", "name": "网络歌曲榜", "bangid": "28"}, + {"id": "tx__5", "name": "内地榜", "bangid": "5"}, + {"id": "tx__3", "name": "欧美榜", "bangid": "3"}, + {"id": "tx__59", "name": "香港地区榜", "bangid": "59"}, + {"id": "tx__16", "name": "韩国榜", "bangid": "16"}, + {"id": "tx__60", "name": "抖快榜", "bangid": "60"}, + {"id": "tx__29", "name": "影视金曲榜", "bangid": "29"}, + {"id": "tx__17", "name": "日本榜", "bangid": "17"}, + {"id": "tx__52", "name": "腾讯音乐人原创榜", "bangid": "52"}, + {"id": "tx__36", "name": "K歌金曲榜", "bangid": "36"}, + {"id": "tx__61", "name": "台湾地区榜", "bangid": "61"}, + {"id": "tx__63", "name": "DJ舞曲榜", "bangid": "63"}, + {"id": "tx__64", "name": "综艺新歌榜", "bangid": "64"}, + {"id": "tx__65", "name": "国风热歌榜", "bangid": "65"}, + {"id": "tx__67", "name": "听歌识曲榜", "bangid": "67"}, + {"id": "tx__72", "name": "动漫音乐榜", "bangid": "72"}, + {"id": "tx__73", "name": "游戏音乐榜", "bangid": "73"}, + {"id": "tx__75", "name": "有声榜", "bangid": "75"}, + {"id": "tx__131", "name": "校园音乐人排行榜", "bangid": "131"}, +] + +_PERIOD_LIST_RXP = re.compile( + r'<i class="play_cover__btn c_tx_link js_icon_play" data-listkey=".+?" data-listname=".+?" ' + r'data-tid=".+?" data-date=".+?" .+?</i>' +) +_PERIOD_RXP = re.compile(r'data-listname="(.+?)" data-tid=".*?\/(.+?)" data-date="(.+?)" .+?</i>') +PERIOD_URL = "https://c.y.qq.com/node/pc/wk_v15/top.html" + +# bangid → {"name", "bangid", "period"}(原 TS 的 periods 对象,模块级缓存) +_periods: dict[str, dict[str, str]] = {} + + +def filter_data(raw_list: Any) -> list[dict[str, Any]]: + songs: list[dict[str, Any]] = [] + for item in raw_list or []: + file = (item or {}).get("file") or {} + types, _types = build_types(file) + album = item.get("album") or {} + album_name = album.get("name") or "" + album_mid = album.get("mid") or "" + singers = item.get("singer") or [] + songs.append( + { + "singer": format_singer_name(singers, "name"), + "name": item.get("title"), + "albumName": album_name, + "albumId": album_mid, + "source": "tx", + "interval": format_play_time(item.get("interval")), + "songId": item.get("id"), + "albumMid": album_mid, + "strMediaMid": file.get("media_mid"), + "songmid": item.get("mid"), + "img": song_img("" if album_name in ("", "空") else album_mid, singers), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return songs + + +async def _get_periods(bangid: str) -> str: + """抓 top.html 填缓存,返回该榜单的 period;抓不到返回空串(不抛错,接口容忍)。""" + resp = await http_fetch(PERIOD_URL) + html = resp.body if isinstance(resp.body, str) else "" + for tag_html in _PERIOD_LIST_RXP.findall(html): + result = _PERIOD_RXP.search(tag_html) + if not result: + continue + _periods[result.group(2)] = {"name": result.group(1), "bangid": result.group(2), "period": result.group(3)} + info = _periods.get(bangid) + return info["period"] if info else "" + + +async def _list_detail_request(bangid: str, period: str, limit: int) -> dict[str, Any]: + # topid 必须发数字:传字符串 "4" 服务端会回 toplist.code 10006(无数据),传 4 才返回榜单。 + topid: Any = int(bangid) if str(bangid).isdigit() else bangid + resp = await http_fetch( + MUSICU_URL, + method="post", + headers={"User-Agent": UA_MUSICU}, + body={ + "toplist": { + "module": "musicToplist.ToplistInfoServer", + "method": "GetDetail", + "param": {"topid": topid, "num": limit, "period": period}, + }, + "comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859}, + }, + ) + return resp.body if isinstance(resp.body, dict) else {} + + +async def get_boards() -> dict[str, Any]: + return {"list": BOARD_LIST, "source": "tx"} + + +async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + retry_num += 1 + if retry_num > 3: + raise RuntimeError("try max num") + + bare = str(bangid) + info = _periods.get(bare) + period = info["period"] if info else await _get_periods(bare) + + body = await _list_detail_request(bare, period, LIMIT) + if body.get("code") != 0: + return await get_list(bangid, page, retry_num) + + info_list = ((body.get("toplist") or {}).get("data") or {}).get("songInfoList") or [] + return { + "total": len(info_list), + "list": filter_data(info_list), + "limit": LIMIT, + "page": 1, + "source": "tx", + } diff --git a/app/sdk/platforms/tx/lyric.py b/app/sdk/platforms/tx/lyric.py new file mode 100644 index 0000000..f22b3c0 --- /dev/null +++ b/app/sdk/platforms/tx/lyric.py @@ -0,0 +1,276 @@ +"""QQ音乐歌词(移植自 sdk/tx/lyric.ts)。 + +上游返回 QRC(hex 密文),需 decode_qrc 解密后是逐字时间轴格式: + [起始毫秒,持续毫秒]字(起始,持续)字(起始,持续)... +同一份数据要拆成两路: + lyric —— 标准 LRC(把逐字标记剥掉,只留行时间) + yrc —— 逐字歌词(`<相对起始,持续>` 标记 + 文字) +翻译(trans)与罗马音(roma)的对齐由 fix*_time_tag 按 ±100ms 容差匹配行时间。 +""" + +from __future__ import annotations + +import asyncio +import re +from collections.abc import Callable +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.tx.music_info import get_music_info_by_mid +from app.sdk.platforms.tx.qrc_decode import decode_qrc +from app.sdk.platforms.tx.utils import MUSICU_URL + +SUCCESS_CODE = 0 + +_UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36" + +_LINE_TIME = re.compile(r"^\[(\d+),\d+\]") +_LINE_TIME2 = re.compile(r"^\[([\d:.]+)\]") +_WORD_TIME = re.compile(r"\(\d+,\d+\)") +_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+\))") +_WORD_TIME_PARTS = re.compile(r"\((\d+),(\d+)\)") +_TIME_SPLIT = re.compile(r"[:.]") +_TAG_HEAD = re.compile(r'^[\S\s]*?LyricContent="') +_TAG_TAIL = re.compile(r'"\/>[\S\s]*?$') + +# songmid → songId 缓存;promises 用于合并并发请求(同一首歌并发取歌词只查一次详情) +_song_id_map: dict[str, Any] = {} +_promises: dict[str, asyncio.Task[Any]] = {} + + +def ms_format(time_ms: int) -> str: + """毫秒 → `[mm:ss.SSS]`。""" + ms = time_ms % 1000 + total = time_ms // 1000 + minute = total // 60 + second = total % 60 + return f"[{minute:02d}:{second:02d}.{ms:03d}]" + + +def parse_lyric(lrc: str) -> dict[str, str]: + """逐字 QRC 正文 → {lyric: 标准 LRC, yrc: 逐字歌词}。""" + text = lrc.strip().replace("\r", "") + if not text: + return {"lyric": "", "yrc": ""} + + yrc_lines: list[str] = [] + lrc_lines: list[str] = [] + + for raw_line in text.split("\n"): + line = raw_line.strip() + result = _LINE_TIME.search(line) + if not result: + if line.startswith("[offset"): + yrc_lines.append(line) + lrc_lines.append(line) + if _LINE_TIME2.match(line): + lrc_lines.append(line) + continue + + start_ms = int(result.group(1)) + start_str = ms_format(start_ms) + + words = _LINE_TIME.sub("", line, count=1) + lrc_lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}") + + times = _WORD_TIME_ALL.findall(words) + if not times: + continue + marks = [] + for time in times: + parts = _WORD_TIME_PARTS.fullmatch(time) + if parts is None: + continue + marks.append(f"<{max(int(parts.group(1)) - start_ms, 0)},{parts.group(2)}>") + word_arr = _WORD_TIME.split(words) + new_words = "".join( + f"{mark}{word_arr[index] if index < len(word_arr) else ''}" for index, mark in enumerate(marks) + ) + yrc_lines.append(f"{start_str}{new_words}") + + return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)} + + +def parse_rlyric(lrc: str) -> str: + """罗马音(逐字格式)→ 标准 LRC 行。""" + text = lrc.strip().replace("\r", "") + if not text: + return "" + + lines: list[str] = [] + for raw_line in text.split("\n"): + line = raw_line.strip() + result = _LINE_TIME.search(line) + if not result: + continue + start_str = ms_format(int(result.group(1))) + words = _LINE_TIME.sub("", line, count=1) + lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}") + return "\n".join(lines) + + +def remove_tag(text: str) -> str: + """剥离 `<LyricContent="..." />` 外层标签,只留正文。""" + return _TAG_TAIL.sub("", _TAG_HEAD.sub("", text)) + + +def get_intv(interval: str) -> int: + """`mm:ss.SSS` / `m:ss` → 毫秒(沿用原实现的 3600000 系数,两侧比较口径一致)。""" + if not interval: + return 0 + if "." not in interval: + interval += ".0" + parts = _TIME_SPLIT.split(interval) + while len(parts) < 3: + parts.insert(0, "0") + minute, second, ms = parts[0], parts[1], parts[2] + + def _int(value: str) -> int: + try: + return int(value) + except ValueError: + return 0 + + return _int(minute) * 3600000 + _int(second) * 1000 + _int(ms) + + +def _align_times(raw_lyric: str, lrc: str) -> str: + """把 raw_lyric 的行时间替换成 lrc 中时间最近的标签(±100ms 内才算命中)。 + + 与原文一致:lrc 行是一次性指针,未命中的行被丢弃(不做回补)。 + """ + out: list[str] = [] + lrc_lines = lrc.split("\n") + for line in raw_lyric.split("\n"): + result = _LINE_TIME2.match(line) + if not result: + continue + if not _LINE_TIME2.sub("", line, count=1).strip(): + continue + t1 = get_intv(result.group(1)) + + while lrc_lines: + lrc_line = lrc_lines.pop(0) + lrc_result = _LINE_TIME2.match(lrc_line) + if not lrc_result: + continue + if abs(t1 - get_intv(lrc_result.group(1))) < 100: + out.append(_LINE_TIME2.sub(_literal(lrc_result.group(0)), line, count=1)) + break + return "\n".join(out) + + +def _literal(text: str) -> Callable[[re.Match[str]], str]: + r"""替换串按字面量处理(`\` 与组引用需转义;时间标签本身不带这些字符,保持严格)。""" + return lambda _match: text + + +def fix_rlrc_time_tag(rlrc: str, lrc: str) -> str: + return _align_times(rlrc, lrc) + + +def fix_tlrc_time_tag(tlrc: str, lrc: str) -> str: + """翻译行对齐。 + + 原 TS 打算把 `mm:ss.d` 的小数位补齐到 3 位,但写的是 + `time += ''.padStart(3 - n, '0')` —— 对空串补位恒为空,实际是空操作。 + 这里保持同样的换算口径(不补位),否则与 TS 版的 tlyric 对齐结果不一致。 + """ + return _align_times(tlrc, lrc) + + +def parse(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]: + info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""} + if lrc: + parsed = parse_lyric(remove_tag(lrc)) + info["lyric"] = parsed["lyric"] + info["yrc"] = parsed["yrc"] + if rlrc: + info["rlyric"] = fix_rlrc_time_tag(parse_rlyric(remove_tag(rlrc)), info["lyric"]) + if tlrc: + info["tlyric"] = fix_tlrc_time_tag(tlrc, info["lyric"]) + return info + + +async def parse_lyric_hex(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]: + """三路 QRC hex 解密后解析。""" + lyric, tlyric, rlyric = decode_qrc(lrc), decode_qrc(tlrc), decode_qrc(rlrc) + return parse(lyric, tlyric, rlyric) + + +async def get_song_id(song_info: dict[str, Any]) -> Any: + """songId 优先;否则用 songmid 反查(带缓存 + 并发合并)。""" + song_id = song_info.get("songId") + if song_id: + return song_id + songmid = str(song_info.get("songmid") or "") + if not songmid: + raise RuntimeError("获取歌曲信息失败") + if songmid in _song_id_map: + return _song_id_map[songmid] + + task = _promises.get(songmid) + if task is None: + task = asyncio.ensure_future(get_music_info_by_mid(songmid)) + _promises[songmid] = task + try: + info = await asyncio.shield(task) + finally: + _promises.pop(songmid, None) + if not info: + raise RuntimeError("获取歌曲信息失败") + _song_id_map[songmid] = info.get("songId") + return info.get("songId") + + +async def _fetch(song_id: Any, retry_num: int) -> dict[str, str]: + resp = await http_fetch( + MUSICU_URL, + method="post", + headers={"referer": "https://y.qq.com", "user-agent": _UA}, + body={ + "comm": {"ct": "19", "cv": "1859", "uin": "0"}, + "req": { + "method": "GetPlayLyricInfo", + "module": "music.musichallSong.PlayLyricInfo", + "param": { + "format": "json", + "crypt": 1, + "ct": 19, + "cv": 1873, + "interval": 0, + "lrc_t": 0, + "qrc": 1, + "qrc_t": 0, + "roma": 1, + "roma_t": 0, + "songID": song_id, + "trans": 1, + "trans_t": 0, + "type": -1, + }, + }, + }, + ) + body = resp.body if isinstance(resp.body, dict) else {} + req = body.get("req") or {} + data = req.get("data") or {} + if body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE: + return await _retry(song_id, retry_num) + return await parse_lyric_hex(data.get("lyric"), data.get("trans"), data.get("roma")) + + +async def _retry(song_id: Any, retry_num: int) -> dict[str, str]: + """失败重试(上限 3 次,文案与原文一致:Get lyric failed)。""" + retry_num += 1 + if retry_num > 3: + raise RuntimeError("Get lyric failed") + # 原实现在此处把 songId 当 song_info 再走一次 getSongId(会解析成空 mid 而必然失败), + # 这里直接复用已取到的 songId 重发同一请求,降级链(同接口重试)保持一致。 + return await _fetch(song_id, retry_num) + + +async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]: + """Platform.get_lyric 契约:song_info → {lyric, tlyric, rlyric, yrc}。""" + song_id = await get_song_id(song_info) + return await _fetch(song_id, 0) diff --git a/app/sdk/platforms/tx/music_info.py b/app/sdk/platforms/tx/music_info.py new file mode 100644 index 0000000..cb67e3b --- /dev/null +++ b/app/sdk/platforms/tx/music_info.py @@ -0,0 +1,108 @@ +"""QQ音乐歌曲信息(移植自 sdk/tx/musicInfo.ts)。 + +走 musicu.fcg 的 music.pf_song_detail_svr/get_song_detail_yqq:songmid → track_info。 +歌词与评论模块靠它把 songmid 换成数字 songID(接口只认数字 id)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_time, size_formate +from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU + + +def _build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]: + """file.size_* → types/_types(0 表示该档不存在,与原文一致)。""" + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for key, quality in ( + ("size_128mp3", "128k"), + ("size_320mp3", "320k"), + ("size_flac", "flac"), + ("size_hires", "flac24bit"), + ): + if file.get(key): + size = size_formate(file[key]) + types.append({"type": quality, "size": size}) + _types[quality] = {"size": size} + return types, _types + + +def _song_img(album_id: Any, singers: Any) -> str: + if album_id in ("", "空", None): + if isinstance(singers, list) and singers and singers[0].get("mid"): + return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg" + return "" + return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg" + + +async def get_music_info_by_mid(songmid: str) -> dict[str, Any] | None: + """songmid → SongInfo;无 media_mid 时返回 None(原文同样返回 null)。""" + resp = await http_fetch( + MUSICU_URL, + method="post", + headers={"User-Agent": UA_MUSICU}, + 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 {} + req = body.get("req") or {} + if body.get("code") != 0 or req.get("code") != 0: + raise RuntimeError("获取歌曲信息失败") + item = (req.get("data") or {}).get("track_info") or {} + file = item.get("file") or {} + if not file.get("media_mid"): + return None + + types, _types = _build_types(file) + album = item.get("album") or {} + album_name = album.get("name") or "" + album_id = album.get("mid") or "" + singers = item.get("singer") or [] + + return { + "singer": "、".join(str(singer.get("name")) for singer in singers if isinstance(singer, dict)), + "name": item.get("title"), + "albumName": album_name, + "albumId": album_id, + "source": "tx", + "interval": format_play_time(item.get("interval")), + "songId": item.get("id"), + "albumMid": album.get("mid") or "", + "strMediaMid": file.get("media_mid"), + "songmid": item.get("mid"), + "img": _song_img(album_id, singers), + "types": types, + "_types": _types, + "typeUrl": {}, + } + + +async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None: + """Platform.get_music_info 契约:song_info 里取 songmid。""" + songmid = song_info.get("songmid") or song_info.get("hash") or "" + if not songmid: + return None + return await get_music_info_by_mid(str(songmid)) + + +async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]: + """补全缺失的歌名/歌手/封面;取不到时原样返回(不抛错)。""" + if song_info.get("name") and song_info.get("singer"): + return song_info + try: + info = await get_music_info(song_info) + except (RuntimeError, OSError, TimeoutError): + return song_info + if not info: + return song_info + merged = {**info, **{k: v for k, v in song_info.items() if v}} + return merged diff --git a/app/sdk/platforms/tx/music_search.py b/app/sdk/platforms/tx/music_search.py new file mode 100644 index 0000000..b71f05e --- /dev/null +++ b/app/sdk/platforms/tx/music_search.py @@ -0,0 +1,139 @@ +"""QQ音乐搜索(移植自 sdk/tx/musicSearch.ts)。 + +走 musics.fcg 签名通道(zzc 签名 + SearchCgiService/DoSearchForQQMusicMobile)。 +item_song → SongInfo;无 media_mid 的条目直接丢弃(原文同样 skip)。 +""" + +from __future__ import annotations + +import random +from typing import Any + +from app.sdk.common.utils import format_play_time, format_singer_name +from app.sdk.platforms.tx.filter import build_types, song_img +from app.sdk.platforms.tx.utils import sign_request + +DEFAULT_LIMIT = 50 +SUCCESS_CODE = 0 + +# 客户端指纹参数,原样照抄(少一项上游会拒) +_SEARCH_COMM: dict[str, str] = { + "ct": "11", + "cv": "14090508", + "v": "14090508", + "tmeAppID": "qqmusic", + "phonetype": "EBG-AN10", + "deviceScore": "553.47", + "devicelevel": "50", + "newdevicelevel": "20", + "rom": "HuaWei/EMOTION/EmotionUI_14.2.0", + "os_ver": "12", + "OpenUDID": "0", + "OpenUDID2": "0", + "QIMEI36": "0", + "udid": "0", + "chid": "0", + "aid": "0", + "oaid": "0", + "taid": "0", + "tid": "0", + "wid": "0", + "uid": "0", + "sid": "0", + "modeSwitch": "6", + "teenMode": "0", + "ui_mode": "2", + "nettype": "1020", + "v4ip": "", +} + + +def _search_id() -> str: + """对应原实现的 `Math.random().toString().slice(2)`:去掉 `0.` 前缀的随机数字串。""" + return str(random.random()).split(".", 1)[-1] + + +def handle_result(raw_list: Any) -> list[dict[str, Any]]: + if not raw_list or not isinstance(raw_list, list): + return [] + songs: list[dict[str, Any]] = [] + for item in raw_list: + file = (item or {}).get("file") or {} + if not file.get("media_mid"): + continue + types, _types = build_types(file) + album = item.get("album") or {} + album_mid = album.get("mid") or "" + singers = item.get("singer") or [] + songs.append( + { + "singer": format_singer_name(singers, "name"), + "name": item.get("title"), + "albumName": album.get("name") or "", + "albumId": album_mid, + "source": "tx", + "interval": format_play_time(item.get("interval")), + "songId": item.get("id"), + "albumMid": album_mid, + "strMediaMid": file.get("media_mid"), + "songmid": item.get("mid"), + "img": song_img(album_mid, singers), + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return songs + + +async def _music_search(keyword: str, page: int, limit: int, retry_num: int) -> dict[str, Any]: + """签名通道搜索。重试上限 5 次(文案与原文一致:搜索失败)。""" + if retry_num > 5: + raise RuntimeError("搜索失败") + resp = await sign_request( + { + "comm": _SEARCH_COMM, + "req": { + "module": "music.search.SearchCgiService", + "method": "DoSearchForQQMusicMobile", + "param": { + "search_type": 0, + "searchid": _search_id(), + "query": keyword, + "page_num": page, + "num_per_page": limit, + "highlight": 0, + "nqc_flag": 0, + "multi_zhida": 0, + "cat": 2, + "grp": 1, + "sin": 0, + "sem": 0, + }, + }, + } + ) + body = resp.body if isinstance(resp.body, dict) else {} + req = body.get("req") or {} + if not req or body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE: + return await _music_search(keyword, page, limit, retry_num + 1) + return req.get("data") or {} + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + if not limit: + limit = DEFAULT_LIMIT + data = await _music_search(keyword, page, limit, retry_num) + # 响应结构:req.data.{body, meta}。歌曲数组在 data.body.item_song,总数在 data.meta.estimate_sum + # (TS 侧写的是 body.item_song / meta.estimate_sum,其中 body 即此处的 data)。 + inner = data.get("body") + meta = data.get("meta") + songs = handle_result(inner.get("item_song") if isinstance(inner, dict) else None) + total = (meta.get("estimate_sum") or 0) if isinstance(meta, dict) else 0 + return { + "list": songs, + "allPage": -(-int(total) // limit) if total else 0, # ceil 除法 + "limit": limit, + "total": total, + "source": "tx", + } diff --git a/app/sdk/platforms/tx/parse.py b/app/sdk/platforms/tx/parse.py new file mode 100644 index 0000000..7c82763 --- /dev/null +++ b/app/sdk/platforms/tx/parse.py @@ -0,0 +1,153 @@ +"""QQ音乐链接解析(移植自 sdk/tx/parse.ts)。 + +歌曲链接形态: + * https://y.qq.com/n/ryqq/songDetail/<songmid> + * https://y.qq.com/n/yqq/song/<songmid>.html + * https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid) + * ...?songmid=<songmid> + +元数据走 fcg_play_single_song(顺带拿 file 体积信息)。直链 url/type 是尽力附带: +取流失败只返回元数据,不影响解析结果(与上游一致,VIP/下架曲目即如此)。 +""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_time, format_singer_name, size_formate +from app.sdk.platforms.tx.filter import album_img + +_UA = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 " + "(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" +) + +_SONG_DETAIL_RE = re.compile(r"songDetail/([A-Za-z0-9]+)") +_SONG_PATH_RE = re.compile(r"/song/([A-Za-z0-9]+)") +_SONG_MID_QUERY_RE = re.compile(r"[?&]songmid=([A-Za-z0-9]+)") +_SONG_ID_QUERY_RE = re.compile(r"[?&]songid=(\d+)") + +_DETAIL_URL = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg" + + +async def _fetch_single_song(query: str) -> dict[str, Any] | None: + """fcg_play_single_song 首条(query 形如 songmid=xxx / songid=123)。""" + resp = await http_fetch( + f"{_DETAIL_URL}?{query}&format=json", + headers={"User-Agent": _UA, "Referer": "http://m.y.qq.com"}, + timeout=15000, + ) + if resp.status_code != 200: + return None + body = resp.body if isinstance(resp.body, dict) else {} + data = body.get("data") + if not isinstance(data, list) or not data or not isinstance(data[0], dict): + return None + return data[0] + + +async def fetch_mid_by_song_id(songid: str) -> str: + """上游 fetchSongDetailByID:数字 songid → mid。""" + item = await _fetch_single_song(f"songid={quote(str(songid), safe='')}") + mid = str((item or {}).get("mid") or "") + if not mid: + raise RuntimeError("QQ音乐歌曲不存在") + return mid + + +async def fetch_song_detail_raw(songmid: str) -> dict[str, Any]: + """上游 fetchSongDetail:songmid → 原始条目。""" + item = await _fetch_single_song(f"songmid={quote(str(songmid), safe='')}") + if not item or not item.get("mid"): + raise RuntimeError("QQ音乐歌曲不存在") + return item + + +def convert_song(item: dict[str, Any]) -> dict[str, Any]: + """原始条目 → MusicServer Song 形状。 + + 注意体积字段的判定与 filter.build_types 不同:这里要求 `isinstance(number)` 且 > 0 + (原文是 `typeof size === 'number' && size > 0`),因此字符串型体积会被跳过。 + """ + file = item.get("file") or {} + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + for key, quality in ( + ("size_128mp3", "128k"), + ("size_320mp3", "320k"), + ("size_flac", "flac"), + ("size_hires", "flac24bit"), + ): + size = file.get(key) + if isinstance(size, (int, float)) and not isinstance(size, bool) and size > 0: + formatted = size_formate(size) + types.append({"type": quality, "size": formatted}) + _types[quality] = {"size": formatted} + + album = item.get("album") or {} + album_mid = album.get("mid") or "" + return { + "source": "tx", + "songmid": str(item.get("mid")), + "songId": item.get("id"), + "name": item.get("title") or item.get("name") or "", + "singer": format_singer_name(item.get("singer"), "name"), + "albumId": album_mid, + "albumMid": album_mid, + "albumName": album.get("name") or "", + "interval": format_play_time(item.get("interval") or 0), + "img": album_img(album_mid) if album_mid else "", + "lrc": None, + "strMediaMid": file.get("media_mid") or item.get("mid"), + "types": types, + "_types": _types, + "typeUrl": {}, + } + + +async def parse_song(link: str) -> dict[str, Any]: + """歌曲链接 → Song(尽力附带直链 url/type)。""" + raw = str(link or "") + match = _SONG_DETAIL_RE.search(raw) or _SONG_PATH_RE.search(raw) + songmid = match.group(1) if match else "" + songid = "" + if not songmid: + id_match = _SONG_ID_QUERY_RE.search(raw) + songid = id_match.group(1) if id_match else "" + mid_match = _SONG_MID_QUERY_RE.search(raw) + songmid = mid_match.group(1) if mid_match else "" + if not songmid and songid: + songmid = await fetch_mid_by_song_id(songid) + if not songmid: + raise RuntimeError("无效的QQ音乐歌曲链接") + + item = await fetch_song_detail_raw(songmid) + song = convert_song(item) + if songid: + song["songId"] = int(songid) + try: + # 延迟导入:apis.tx 反向依赖 platforms.tx.utils,模块级互 import 会拿到半初始化模块 + from app.sdk.apis import tx as tx_api + + hit = await tx_api.get_music_url(song, "320k") + song["url"] = hit.get("url") + song["type"] = hit.get("type") + except (RuntimeError, OSError, TimeoutError): + # 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据) + pass + return song + + +async def parse_album(link: str) -> dict[str, Any]: + from app.sdk.platforms.tx.album import parse_album_link + + return await parse_album_link(link) + + +async def parse_playlist(link: str) -> dict[str, Any]: + from app.sdk.platforms.tx.playlist import parse_playlist_link + + return await parse_playlist_link(link) diff --git a/app/sdk/platforms/tx/pic.py b/app/sdk/platforms/tx/pic.py new file mode 100644 index 0000000..22553c9 --- /dev/null +++ b/app/sdk/platforms/tx/pic.py @@ -0,0 +1,14 @@ +"""QQ音乐封面(移植自 sdk/tx/index.ts 的 getPic)。 + +封面是固定 URL 规则:T002 = 专辑图,按 albumId/albumMid 拼。路由总是传 albumId(缺省空串), +这里对 None 也按空串处理(原 JS 会把 undefined 拼成字面量 "undefined",无实际意义)。 +""" + +from __future__ import annotations + +from typing import Any + + +async def get_pic(song_info: dict[str, Any]) -> str: + album_id = song_info.get("albumId") or "" + return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg" diff --git a/app/sdk/platforms/tx/playlist.py b/app/sdk/platforms/tx/playlist.py new file mode 100644 index 0000000..d407f50 --- /dev/null +++ b/app/sdk/platforms/tx/playlist.py @@ -0,0 +1,299 @@ +"""QQ音乐歌单能力位(移植自 sdk/tx/playlist.ts)。 + +复用 song_list 的搜索/分类/详情,另补三块上游独有的逻辑: + * 分类歌单按入参 limit 重新切片(上游固定 36 条一页,需多次拉取拼够) + * 推荐歌单(musicu.fcg playlist.HotRecommendServer/get_hot_recommend) + * 歌单链接解析(extract_playlist_id 支持现代 ryqq 路由 / 老版 id、disstid 查询, + 并显式拒绝歌曲/专辑链接,避免把其中的数字 id 误判成歌单) +""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import parse_qs, urlparse + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_count +from app.sdk.platforms.tx import song_list +from app.sdk.platforms.tx.utils import MUSICU_URL + +_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +_API_LIMIT = song_list.LIMIT_LIST +_DIGITS = re.compile(r"^\d+$") +# 歌曲/专辑页特征:出现即拒绝(歌单链接可能带 ?id=,故先看是否含 playlist) +_NON_PLAYLIST = re.compile(r"(albumDetail|/album/|albummid=|songDetail|/song/|songmid=|[?&]songid=)") +_BARE_PLAYLIST_PATH = re.compile(r"(?:^|/)playlist/(\d+)(?:[/?#]|$)") + + +def _playlist_link(playlist_id: str) -> str: + return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}" + + +def _to_summary(item: Any) -> dict[str, Any] | None: + """歌单摘要:兼容 song_list 的既有字段并补 spec 形状。""" + if not isinstance(item, dict): + return None + playlist_id = str(item.get("id") or "").strip() + if not playlist_id: + return None + cover = str(item.get("img") or item.get("cover") or "") + track_count = item.get("total") + return { + **item, + "id": playlist_id, + "name": item.get("name") or "", + "cover": cover, + "trackCount": int(track_count) if isinstance(track_count, (int, float)) else 0, + "creator": item.get("author") or item.get("creator") or "", + "description": item.get("desc") or item.get("description") or "", + "link": _playlist_link(playlist_id), + "source": "tx", + } + + +def _normalize_playlist_id(value: str) -> str: + trimmed = str(value or "").strip() + return trimmed if trimmed and _DIGITS.match(trimmed) else "" + + +def _filter_category_content(raw: Any) -> list[dict[str, Any]]: + """分类内容 → 摘要(补 song_cnt/play_cnt,song_list.filter_list2 未映射这两个字段)。""" + rows: list[dict[str, Any]] = [] + for item in raw or []: + basic = (item or {}).get("basic") or {} + playlist_id = str(basic.get("tid") or "").strip() + name = str(basic.get("title") or "").strip() + if not playlist_id or not name: + continue + play_count = basic.get("play_cnt") or 0 + cover = str((basic.get("cover") or {}).get("medium_url") or (basic.get("cover") or {}).get("default_url") or "") + desc = str(basic.get("desc") or "").replace("<br>", "\n") + track_count = basic.get("song_cnt") or 0 + rows.append( + { + "id": playlist_id, + "name": name, + "img": cover, + "cover": cover, + "author": (basic.get("creator") or {}).get("nick") or "", + "creator": (basic.get("creator") or {}).get("nick") or "", + "play_count": format_play_count(play_count), + "playCount": int(play_count), + "trackCount": int(track_count), + "total": int(track_count), + "desc": desc, + "description": desc, + "source": "tx", + } + ) + return rows + + +async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌单搜索(复用 song_list.search)。""" + result = await song_list.search(str(keyword or ""), page, limit) + rows = [summary for summary in (_to_summary(item) for item in result.get("list") or []) if summary] + total = result.get("total") + return {"list": rows, "page": page, "limit": limit, "total": total or len(rows), "source": "tx"} + + +async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌单详情:详情接口返回全量曲目,这里按 page/limit 切片。""" + detail = await song_list.get_list_detail(str(playlist_id or "")) + songs = detail.get("list") or [] + start = max(0, (page - 1) * limit) + total = detail.get("total") + return { + "source": "tx", + "info": detail.get("info"), + "list": songs[start : start + limit], + "page": page, + "limit": limit, + "total": total if total is not None else len(songs), + } + + +async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]: + detail = await song_list.get_list_detail(str(playlist_id or "")) + return detail.get("list") or [] + + +async def get_categories() -> dict[str, Any]: + """歌单分类:分组标签 + 热门标记(单个接口失败不阻塞整体)。""" + tags = await song_list.get_tags() + groups = tags.get("tags") or [] + hot_tags = tags.get("hotTag") or [] + + hot_set = {str(item.get("id")) for item in hot_tags if isinstance(item, dict)} + rows: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}] + seen = {""} + for group in groups: + for item in group.get("list") or []: + category_id = str(item.get("id") or "").strip() + name = str(item.get("name") or "").strip() + if not category_id or not name or category_id in seen: + continue + seen.add(category_id) + rows.append( + {"id": category_id, "name": name, "group": group.get("name") or "", "hot": category_id in hot_set} + ) + return {"source": "tx", "list": rows} + + +async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """分类歌单。上游固定 36 条一页,按 (start+limit) 推算需要拉几页再切片。 + + categoryId 为空或 '10000000'(上游「全部」默认值)时走 get_playlist_by_tag 广场分支。 + """ + category = str(category_id or "").strip() + is_all = category in ("", "10000000") + start = max(0, (page - 1) * limit) + api_pages = max(1, -(-(start + limit) // _API_LIMIT)) # ceil 除法 + + items: list[dict[str, Any]] = [] + total = 0 + for api_page in range(1, api_pages + 1): + url = song_list.get_list_url("5", "" if is_all else category, api_page) + resp = await http_fetch(url, timeout=15000) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 0: + raise RuntimeError(f"tx 分类歌单获取失败 (code {body.get('code') or resp.status_code})") + data = (body.get("playlist") or {}).get("data") or {} + if is_all: + total = int(data.get("total") or total) + batch = [] + for item in data.get("v_playlist") or []: + play_count = item.get("access_num") or 0 + playlist_id = str(item.get("tid") or "").strip() + name = str(item.get("title") or "").strip() + if not playlist_id or not name: + continue + batch.append( + { + "id": playlist_id, + "name": name, + "cover": str(item.get("cover_url_medium") or item.get("cover_url_big") or ""), + "creator": (item.get("creator_info") or {}).get("nick") or "", + "trackCount": len(item.get("song_ids") or []), + "playCount": int(play_count), + "play_count": format_play_count(play_count), + "description": str(item.get("desc") or "").replace("<br>", "\n"), + } + ) + items.extend(batch) + else: + content = data.get("content") or {} + total = int(content.get("total_cnt") or total) + batch = _filter_category_content(content.get("v_item")) + items.extend(batch) + if len(batch) < _API_LIMIT: + break + + rows = [summary for summary in (_to_summary(item) for item in items[start : start + limit]) if summary] + return {"list": rows, "page": page, "limit": limit, "total": total or len(items), "source": "tx"} + + +async def get_recommended(page: int = 1, limit: int = 20) -> dict[str, Any]: + """推荐歌单(playlist.HotRecommendServer/get_hot_recommend)。""" + resp = await http_fetch( + MUSICU_URL, + method="post", + headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Content-Type": "application/json"}, + body={ + "comm": {"ct": 24}, + "recomPlaylist": { + "method": "get_hot_recommend", + "module": "playlist.HotRecommendServer", + "param": {"async": 1, "cmd": 2}, + }, + }, + timeout=15000, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 0: + raise RuntimeError(f"tx 推荐歌单获取失败 (code {body.get('code') or resp.status_code})") + + rows: list[dict[str, Any]] = [] + for item in ((body.get("recomPlaylist") or {}).get("data") or {}).get("v_hot") or []: + playlist_id = str( + item.get("content_id") or item.get("dissid") or item.get("id") or item.get("tid") or "" + ).strip() + name = str(item.get("title") or item.get("dissname") or item.get("name") or "").strip() + if not playlist_id or not name: + continue + cover = str(item.get("cover") or item.get("imgurl") or "") + play_count = item.get("listen_num") or item.get("listennum") or 0 + rows.append( + { + "id": playlist_id, + "name": name, + "cover": cover.replace("http://", "https://", 1) if cover.startswith("http://") else cover, + "trackCount": int(item.get("song_cnt") or item.get("song_count") or item.get("songnum") or 0), + "playCount": int(play_count), + "play_count": format_play_count(play_count), + "creator": str(item.get("username") or (item.get("creator") or {}).get("nick") or "").strip(), + "description": "", + "link": _playlist_link(playlist_id), + "source": "tx", + } + ) + if not rows: + raise RuntimeError("no recommended playlists found") + + start = max(0, (page - 1) * limit) + return {"list": rows[start : start + limit], "page": page, "limit": limit, "total": len(rows), "source": "tx"} + + +async def extract_playlist_id(link: str) -> str | None: + """链接 → 数字歌单 id;非歌单链接返回 None。""" + raw = str(link or "").strip() + if not raw: + return None + + try: + parsed = urlparse(raw) + except ValueError: + parsed = None + if parsed is not None and parsed.scheme: + path = parsed.path.lower() + if "playlist" in path: + query = parse_qs(parsed.query) + for key in ("id", "disstid", "dissid"): + for value in query.get(key) or []: + found = _normalize_playlist_id(value) + if found: + return found + parts = [part for part in parsed.path.strip("/").split("/") if part] + for index, part in enumerate(parts): + if part.lower() == "playlist" and index + 1 < len(parts): + found = _normalize_playlist_id(parts[index + 1]) + if found: + return found + + match = _BARE_PLAYLIST_PATH.search(raw) + return match.group(1) if match else None + + +async def parse_playlist_link(link: str) -> dict[str, Any]: + """歌单链接解析:提取 disstid 后取详情;短链走 song_list.get_list_id 兜底。""" + raw = str(link or "").strip() + if not raw: + raise RuntimeError("无效的QQ音乐歌单链接") + if not re.search(r"playlist", raw, re.IGNORECASE) and _NON_PLAYLIST.search(raw): + raise RuntimeError("无效的QQ音乐歌单链接") + + playlist_id = await extract_playlist_id(raw) + if not playlist_id and not re.search(r"\d", raw): + raise RuntimeError("无效的QQ音乐歌单链接") + if not playlist_id: + try: + playlist_id = await song_list.get_list_id(raw) + except (RuntimeError, OSError, TimeoutError): + playlist_id = "" + if not playlist_id: + raise RuntimeError("无效的QQ音乐歌单链接") + return await get_playlist_detail(playlist_id) + + +get_recommended_playlists = get_recommended diff --git a/app/sdk/platforms/tx/qrc_decode.py b/app/sdk/platforms/tx/qrc_decode.py new file mode 100644 index 0000000..4385e38 --- /dev/null +++ b/app/sdk/platforms/tx/qrc_decode.py @@ -0,0 +1,1082 @@ +"""QRC 歌词解密(移植自 sdk/tx/qrcDecode.ts)。 + +数据流程:hex 字符串 → 字节 → 3DES(DES-EDE3, ECB, 无填充) 解密 → zlib inflate → UTF-8 文本。 + +注意:QRC 用的是**非标准** DES 变体,S-box 中 S2[23]=15、S4[53]=10 两处与标准 DES +(14、1)不同。这两个值与原 C++ native 插件一致,改成标准值会导致解密结果全错。 +下表逐字节对应原实现,不要「顺手修正」。 + +原 TS 的 inflate 用 `finishFlush: Z_SYNC_FLUSH` 容忍尾部不完整流(3DES 分组补位会产生 +尾部垃圾/截断),Python 侧同样先尝试完整解压,失败后分块累积已解出部分。 +""" + +from __future__ import annotations + +import zlib +from collections.abc import Sequence + +DES_ENCRYPT = 1 +DES_DECRYPT = 0 + +_MASK32 = 0xFFFFFFFF +_MASK8 = 0xFF + +SBOX: tuple[tuple[int, ...], ...] = ( + ( + 14, + 4, + 13, + 1, + 2, + 15, + 11, + 8, + 3, + 10, + 6, + 12, + 5, + 9, + 0, + 7, + 0, + 15, + 7, + 4, + 14, + 2, + 13, + 1, + 10, + 6, + 12, + 11, + 9, + 5, + 3, + 8, + 4, + 1, + 14, + 8, + 13, + 6, + 2, + 11, + 15, + 12, + 9, + 7, + 3, + 10, + 5, + 0, + 15, + 12, + 8, + 2, + 4, + 9, + 1, + 7, + 5, + 11, + 3, + 14, + 10, + 0, + 6, + 13, + ), + ( + 15, + 1, + 8, + 14, + 6, + 11, + 3, + 4, + 9, + 7, + 2, + 13, + 12, + 0, + 5, + 10, + 3, + 13, + 4, + 7, + 15, + 2, + 8, + 15, + 12, + 0, + 1, + 10, + 6, + 9, + 11, + 5, + 0, + 14, + 7, + 11, + 10, + 4, + 13, + 1, + 5, + 8, + 12, + 6, + 9, + 3, + 2, + 15, + 13, + 8, + 10, + 1, + 3, + 15, + 4, + 2, + 11, + 6, + 7, + 12, + 0, + 5, + 14, + 9, + ), + ( + 10, + 0, + 9, + 14, + 6, + 3, + 15, + 5, + 1, + 13, + 12, + 7, + 11, + 4, + 2, + 8, + 13, + 7, + 0, + 9, + 3, + 4, + 6, + 10, + 2, + 8, + 5, + 14, + 12, + 11, + 15, + 1, + 13, + 6, + 4, + 9, + 8, + 15, + 3, + 0, + 11, + 1, + 2, + 12, + 5, + 10, + 14, + 7, + 1, + 10, + 13, + 0, + 6, + 9, + 8, + 7, + 4, + 15, + 14, + 3, + 11, + 5, + 2, + 12, + ), + ( + 7, + 13, + 14, + 3, + 0, + 6, + 9, + 10, + 1, + 2, + 8, + 5, + 11, + 12, + 4, + 15, + 13, + 8, + 11, + 5, + 6, + 15, + 0, + 3, + 4, + 7, + 2, + 12, + 1, + 10, + 14, + 9, + 10, + 6, + 9, + 0, + 12, + 11, + 7, + 13, + 15, + 1, + 3, + 14, + 5, + 2, + 8, + 4, + 3, + 15, + 0, + 6, + 10, + 10, + 13, + 8, + 9, + 4, + 5, + 11, + 12, + 7, + 2, + 14, + ), + ( + 2, + 12, + 4, + 1, + 7, + 10, + 11, + 6, + 8, + 5, + 3, + 15, + 13, + 0, + 14, + 9, + 14, + 11, + 2, + 12, + 4, + 7, + 13, + 1, + 5, + 0, + 15, + 10, + 3, + 9, + 8, + 6, + 4, + 2, + 1, + 11, + 10, + 13, + 7, + 8, + 15, + 9, + 12, + 5, + 6, + 3, + 0, + 14, + 11, + 8, + 12, + 7, + 1, + 14, + 2, + 13, + 6, + 15, + 0, + 9, + 10, + 4, + 5, + 3, + ), + ( + 12, + 1, + 10, + 15, + 9, + 2, + 6, + 8, + 0, + 13, + 3, + 4, + 14, + 7, + 5, + 11, + 10, + 15, + 4, + 2, + 7, + 12, + 9, + 5, + 6, + 1, + 13, + 14, + 0, + 11, + 3, + 8, + 9, + 14, + 15, + 5, + 2, + 8, + 12, + 3, + 7, + 0, + 4, + 10, + 1, + 13, + 11, + 6, + 4, + 3, + 2, + 12, + 9, + 5, + 15, + 10, + 11, + 14, + 1, + 7, + 6, + 0, + 8, + 13, + ), + ( + 4, + 11, + 2, + 14, + 15, + 0, + 8, + 13, + 3, + 12, + 9, + 7, + 5, + 10, + 6, + 1, + 13, + 0, + 11, + 7, + 4, + 9, + 1, + 10, + 14, + 3, + 5, + 12, + 2, + 15, + 8, + 6, + 1, + 4, + 11, + 13, + 12, + 3, + 7, + 14, + 10, + 15, + 6, + 8, + 0, + 5, + 9, + 2, + 6, + 11, + 13, + 8, + 1, + 4, + 10, + 7, + 9, + 5, + 0, + 15, + 14, + 2, + 3, + 12, + ), + ( + 13, + 2, + 8, + 4, + 6, + 15, + 11, + 1, + 10, + 9, + 3, + 14, + 5, + 0, + 12, + 7, + 1, + 15, + 13, + 8, + 10, + 3, + 7, + 4, + 12, + 5, + 6, + 11, + 0, + 14, + 9, + 2, + 7, + 11, + 4, + 1, + 9, + 12, + 14, + 2, + 0, + 6, + 10, + 13, + 15, + 3, + 5, + 8, + 2, + 1, + 14, + 7, + 4, + 10, + 8, + 13, + 15, + 12, + 9, + 0, + 3, + 5, + 6, + 11, + ), +) + +KEY_RND_SHIFT = (1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1) +KEY_PERM_C = ( + 56, + 48, + 40, + 32, + 24, + 16, + 8, + 0, + 57, + 49, + 41, + 33, + 25, + 17, + 9, + 1, + 58, + 50, + 42, + 34, + 26, + 18, + 10, + 2, + 59, + 51, + 43, + 35, +) +KEY_PERM_D = ( + 62, + 54, + 46, + 38, + 30, + 22, + 14, + 6, + 61, + 53, + 45, + 37, + 29, + 21, + 13, + 5, + 60, + 52, + 44, + 36, + 28, + 20, + 12, + 4, + 27, + 19, + 11, + 3, +) +KEY_COMPRESSION = ( + 13, + 16, + 10, + 23, + 0, + 4, + 2, + 27, + 14, + 5, + 20, + 9, + 22, + 18, + 11, + 3, + 25, + 7, + 15, + 6, + 26, + 19, + 12, + 1, + 40, + 51, + 30, + 36, + 46, + 54, + 29, + 39, + 50, + 44, + 32, + 47, + 43, + 48, + 38, + 55, + 33, + 52, + 45, + 41, + 49, + 35, + 28, + 31, +) + +# QRC 固定密钥:!@#)(*$%123ZXC!@!@#)(NHL +QRC_KEY = bytes( + ( + 0x21, + 0x40, + 0x23, + 0x29, + 0x28, + 0x2A, + 0x24, + 0x25, + 0x31, + 0x32, + 0x33, + 0x5A, + 0x58, + 0x43, + 0x21, + 0x40, + 0x21, + 0x40, + 0x23, + 0x29, + 0x28, + 0x4E, + 0x48, + 0x4C, + ) +) + +Schedule = list[bytearray] + + +def _bitnum(buf: bytes, bit: int, shift: int) -> int: + """按 64 位大端位序取 buf 的第 bit 位(bit: 0=MSB),左移 shift。""" + index = (bit // 32) * 4 + 3 - ((bit % 32) // 8) + return (((buf[index] >> (7 - (bit % 8))) & 1) << shift) & _MASK32 + + +def _bitnum_intr(value: int, bit: int, shift: int) -> int: + return (((value >> (31 - bit)) & 1) << shift) & _MASK32 + + +def _bitnum_intl(value: int, bit: int, shift: int) -> int: + return (((value << bit) & 0x80000000) & _MASK32) >> shift + + +def _sbox_bit(value: int) -> int: + return (value & 32) | ((value & 31) >> 1) | ((value & 1) << 4) + + +def _initial_permutation(block: bytes) -> tuple[int, int]: + s0 = ( + _bitnum(block, 57, 31) + | _bitnum(block, 49, 30) + | _bitnum(block, 41, 29) + | _bitnum(block, 33, 28) + | _bitnum(block, 25, 27) + | _bitnum(block, 17, 26) + | _bitnum(block, 9, 25) + | _bitnum(block, 1, 24) + | _bitnum(block, 59, 23) + | _bitnum(block, 51, 22) + | _bitnum(block, 43, 21) + | _bitnum(block, 35, 20) + | _bitnum(block, 27, 19) + | _bitnum(block, 19, 18) + | _bitnum(block, 11, 17) + | _bitnum(block, 3, 16) + | _bitnum(block, 61, 15) + | _bitnum(block, 53, 14) + | _bitnum(block, 45, 13) + | _bitnum(block, 37, 12) + | _bitnum(block, 29, 11) + | _bitnum(block, 21, 10) + | _bitnum(block, 13, 9) + | _bitnum(block, 5, 8) + | _bitnum(block, 63, 7) + | _bitnum(block, 55, 6) + | _bitnum(block, 47, 5) + | _bitnum(block, 39, 4) + | _bitnum(block, 31, 3) + | _bitnum(block, 23, 2) + | _bitnum(block, 15, 1) + | _bitnum(block, 7, 0) + ) & _MASK32 + s1 = ( + _bitnum(block, 56, 31) + | _bitnum(block, 48, 30) + | _bitnum(block, 40, 29) + | _bitnum(block, 32, 28) + | _bitnum(block, 24, 27) + | _bitnum(block, 16, 26) + | _bitnum(block, 8, 25) + | _bitnum(block, 0, 24) + | _bitnum(block, 58, 23) + | _bitnum(block, 50, 22) + | _bitnum(block, 42, 21) + | _bitnum(block, 34, 20) + | _bitnum(block, 26, 19) + | _bitnum(block, 18, 18) + | _bitnum(block, 10, 17) + | _bitnum(block, 2, 16) + | _bitnum(block, 60, 15) + | _bitnum(block, 52, 14) + | _bitnum(block, 44, 13) + | _bitnum(block, 36, 12) + | _bitnum(block, 28, 11) + | _bitnum(block, 20, 10) + | _bitnum(block, 12, 9) + | _bitnum(block, 4, 8) + | _bitnum(block, 62, 7) + | _bitnum(block, 54, 6) + | _bitnum(block, 46, 5) + | _bitnum(block, 38, 4) + | _bitnum(block, 30, 3) + | _bitnum(block, 22, 2) + | _bitnum(block, 14, 1) + | _bitnum(block, 6, 0) + ) & _MASK32 + return s0, s1 + + +def _inverse_permutation(s0: int, s1: int, out: bytearray) -> None: + out[3] = ( + _bitnum_intr(s1, 7, 7) + | _bitnum_intr(s0, 7, 6) + | _bitnum_intr(s1, 15, 5) + | _bitnum_intr(s0, 15, 4) + | _bitnum_intr(s1, 23, 3) + | _bitnum_intr(s0, 23, 2) + | _bitnum_intr(s1, 31, 1) + | _bitnum_intr(s0, 31, 0) + ) & _MASK8 + out[2] = ( + _bitnum_intr(s1, 6, 7) + | _bitnum_intr(s0, 6, 6) + | _bitnum_intr(s1, 14, 5) + | _bitnum_intr(s0, 14, 4) + | _bitnum_intr(s1, 22, 3) + | _bitnum_intr(s0, 22, 2) + | _bitnum_intr(s1, 30, 1) + | _bitnum_intr(s0, 30, 0) + ) & _MASK8 + out[1] = ( + _bitnum_intr(s1, 5, 7) + | _bitnum_intr(s0, 5, 6) + | _bitnum_intr(s1, 13, 5) + | _bitnum_intr(s0, 13, 4) + | _bitnum_intr(s1, 21, 3) + | _bitnum_intr(s0, 21, 2) + | _bitnum_intr(s1, 29, 1) + | _bitnum_intr(s0, 29, 0) + ) & _MASK8 + out[0] = ( + _bitnum_intr(s1, 4, 7) + | _bitnum_intr(s0, 4, 6) + | _bitnum_intr(s1, 12, 5) + | _bitnum_intr(s0, 12, 4) + | _bitnum_intr(s1, 20, 3) + | _bitnum_intr(s0, 20, 2) + | _bitnum_intr(s1, 28, 1) + | _bitnum_intr(s0, 28, 0) + ) & _MASK8 + out[7] = ( + _bitnum_intr(s1, 3, 7) + | _bitnum_intr(s0, 3, 6) + | _bitnum_intr(s1, 11, 5) + | _bitnum_intr(s0, 11, 4) + | _bitnum_intr(s1, 19, 3) + | _bitnum_intr(s0, 19, 2) + | _bitnum_intr(s1, 27, 1) + | _bitnum_intr(s0, 27, 0) + ) & _MASK8 + out[6] = ( + _bitnum_intr(s1, 2, 7) + | _bitnum_intr(s0, 2, 6) + | _bitnum_intr(s1, 10, 5) + | _bitnum_intr(s0, 10, 4) + | _bitnum_intr(s1, 18, 3) + | _bitnum_intr(s0, 18, 2) + | _bitnum_intr(s1, 26, 1) + | _bitnum_intr(s0, 26, 0) + ) & _MASK8 + out[5] = ( + _bitnum_intr(s1, 1, 7) + | _bitnum_intr(s0, 1, 6) + | _bitnum_intr(s1, 9, 5) + | _bitnum_intr(s0, 9, 4) + | _bitnum_intr(s1, 17, 3) + | _bitnum_intr(s0, 17, 2) + | _bitnum_intr(s1, 25, 1) + | _bitnum_intr(s0, 25, 0) + ) & _MASK8 + out[4] = ( + _bitnum_intr(s1, 0, 7) + | _bitnum_intr(s0, 0, 6) + | _bitnum_intr(s1, 8, 5) + | _bitnum_intr(s0, 8, 4) + | _bitnum_intr(s1, 16, 3) + | _bitnum_intr(s0, 16, 2) + | _bitnum_intr(s1, 24, 1) + | _bitnum_intr(s0, 24, 0) + ) & _MASK8 + + +def _des_f(state: int, key: Sequence[int]) -> int: + """Feistel 轮函数。""" + t1 = ( + _bitnum_intl(state, 31, 0) + | ((state & 0xF0000000) >> 1) + | _bitnum_intl(state, 4, 5) + | _bitnum_intl(state, 3, 6) + | ((state & 0x0F000000) >> 3) + | _bitnum_intl(state, 8, 11) + | _bitnum_intl(state, 7, 12) + | ((state & 0x00F00000) >> 5) + | _bitnum_intl(state, 12, 17) + | _bitnum_intl(state, 11, 18) + | ((state & 0x000F0000) >> 7) + | _bitnum_intl(state, 16, 23) + ) & _MASK32 + t2 = ( + _bitnum_intl(state, 15, 0) + | (((state & 0x0000F000) << 15) & _MASK32) + | _bitnum_intl(state, 20, 5) + | _bitnum_intl(state, 19, 6) + | (((state & 0x00000F00) << 13) & _MASK32) + | _bitnum_intl(state, 24, 11) + | _bitnum_intl(state, 23, 12) + | (((state & 0x000000F0) << 11) & _MASK32) + | _bitnum_intl(state, 28, 17) + | _bitnum_intl(state, 27, 18) + | (((state & 0x0000000F) << 9) & _MASK32) + | _bitnum_intl(state, 0, 23) + ) & _MASK32 + + lrgstate = [ + (t1 >> 24) & _MASK8, + (t1 >> 16) & _MASK8, + (t1 >> 8) & _MASK8, + (t2 >> 24) & _MASK8, + (t2 >> 16) & _MASK8, + (t2 >> 8) & _MASK8, + ] + for i in range(6): + lrgstate[i] ^= key[i] + + s = ( + (SBOX[0][_sbox_bit(lrgstate[0] >> 2)] << 28) + | (SBOX[1][_sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >> 4))] << 24) + | (SBOX[2][_sbox_bit(((lrgstate[1] & 0x0F) << 2) | (lrgstate[2] >> 6))] << 20) + | (SBOX[3][_sbox_bit(lrgstate[2] & 0x3F)] << 16) + | (SBOX[4][_sbox_bit(lrgstate[3] >> 2)] << 12) + | (SBOX[5][_sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >> 4))] << 8) + | (SBOX[6][_sbox_bit(((lrgstate[4] & 0x0F) << 2) | (lrgstate[5] >> 6))] << 4) + | SBOX[7][_sbox_bit(lrgstate[5] & 0x3F)] + ) & _MASK32 + + return ( + _bitnum_intl(s, 15, 0) + | _bitnum_intl(s, 6, 1) + | _bitnum_intl(s, 19, 2) + | _bitnum_intl(s, 20, 3) + | _bitnum_intl(s, 28, 4) + | _bitnum_intl(s, 11, 5) + | _bitnum_intl(s, 27, 6) + | _bitnum_intl(s, 16, 7) + | _bitnum_intl(s, 0, 8) + | _bitnum_intl(s, 14, 9) + | _bitnum_intl(s, 22, 10) + | _bitnum_intl(s, 25, 11) + | _bitnum_intl(s, 4, 12) + | _bitnum_intl(s, 17, 13) + | _bitnum_intl(s, 30, 14) + | _bitnum_intl(s, 9, 15) + | _bitnum_intl(s, 1, 16) + | _bitnum_intl(s, 7, 17) + | _bitnum_intl(s, 23, 18) + | _bitnum_intl(s, 13, 19) + | _bitnum_intl(s, 31, 20) + | _bitnum_intl(s, 26, 21) + | _bitnum_intl(s, 2, 22) + | _bitnum_intl(s, 8, 23) + | _bitnum_intl(s, 18, 24) + | _bitnum_intl(s, 12, 25) + | _bitnum_intl(s, 29, 26) + | _bitnum_intl(s, 5, 27) + | _bitnum_intl(s, 21, 28) + | _bitnum_intl(s, 10, 29) + | _bitnum_intl(s, 3, 30) + | _bitnum_intl(s, 24, 31) + ) & _MASK32 + + +def _des_crypt(block: bytes, schedule: Sequence[Sequence[int]], out: bytearray) -> None: + """单次 DES 加/解密一个 8 字节分组。""" + s0, s1 = _initial_permutation(block) + for i in range(15): + s0, s1 = s1, (_des_f(s1, schedule[i]) ^ s0) & _MASK32 + s0 = (_des_f(s1, schedule[15]) ^ s0) & _MASK32 + _inverse_permutation(s0, s1, out) + + +def _key_schedule(key: bytes, mode: int) -> Schedule: + """生成 16 轮子密钥,每轮 6 字节。""" + schedule = [bytearray(6) for _ in range(16)] + c = 0 + d = 0 + for i in range(28): + c = (c | _bitnum(key, KEY_PERM_C[i], 31 - i)) & _MASK32 + d = (d | _bitnum(key, KEY_PERM_D[i], 31 - i)) & _MASK32 + for i in range(16): + shift = KEY_RND_SHIFT[i] + c = (((c << shift) & _MASK32) | (c >> (28 - shift))) & 0xFFFFFFF0 + d = (((d << shift) & _MASK32) | (d >> (28 - shift))) & 0xFFFFFFF0 + togen = 15 - i if mode == DES_DECRYPT else i + row = schedule[togen] + for j in range(24): + row[j // 8] |= _bitnum_intr(c, KEY_COMPRESSION[j], 7 - (j % 8)) + for j in range(24, 48): + row[j // 8] |= _bitnum_intr(d, KEY_COMPRESSION[j] - 27, 7 - (j % 8)) + return schedule + + +def _tripledes_key_setup(key: bytes, mode: int) -> tuple[Schedule, Schedule, Schedule]: + """3DES(EDE3) 密钥编排,返回 3 组 16x6 轮密钥。""" + if mode == DES_ENCRYPT: + return ( + _key_schedule(key[0:8], DES_ENCRYPT), + _key_schedule(key[8:16], DES_DECRYPT), + _key_schedule(key[16:24], DES_ENCRYPT), + ) + return ( + _key_schedule(key[16:24], DES_DECRYPT), + _key_schedule(key[8:16], DES_ENCRYPT), + _key_schedule(key[0:8], DES_DECRYPT), + ) + + +def _tripledes_crypt(block: bytes, schedule: tuple[Schedule, Schedule, Schedule]) -> bytes: + """3DES 加/解密一个 8 字节分组(ECB,无填充)。""" + buf = bytearray(8) + mid = bytearray(8) + _des_crypt(block, schedule[0], buf) + _des_crypt(bytes(buf), schedule[1], mid) + _des_crypt(bytes(mid), schedule[2], buf) + return bytes(buf) + + +def _inflate(data: bytes) -> bytes: + """zlib 解压,容忍尾部不完整/多余字节(对应原实现的 Z_SYNC_FLUSH)。""" + try: + return zlib.decompress(data) + except zlib.error: + pass + + # 流未正常收尾(3DES 分组补位会在尾部留垃圾)时,flush 报错属预期,已解出的部分仍可用 + for wbits in (15, 15 + 32, -15): + try: + decompressor = zlib.decompressobj(wbits) + out = decompressor.decompress(data) + except zlib.error: + continue + try: + out += decompressor.flush() + except zlib.error: + pass + if out: + return out + + # 帧中间损坏时逐块喂入,保住出错点之前已解出的前缀 + for wbits in (15, 15 + 32, -15): + try: + decompressor = zlib.decompressobj(wbits) + except zlib.error: + continue + out = bytearray() + try: + for offset in range(0, len(data), 64): + out += decompressor.decompress(data[offset : offset + 64]) + out += decompressor.flush() + except zlib.error: + if out: + return bytes(out) + continue + if out: + return bytes(out) + return b"" + + +def decode_qrc(hex_data: str | None) -> str: + """解密腾讯 QRC 歌词。 + + :param hex_data: 服务端返回的十六进制字符串 + :return: 解密并解压后的歌词文本(解析失败时返回空字符串) + """ + if not hex_data or len(hex_data) % 2 != 0: + return "" + try: + encrypted = bytes.fromhex(hex_data) + except ValueError: + return "" + if not encrypted: + return "" + + schedule = _tripledes_key_setup(QRC_KEY, DES_DECRYPT) + + plain = bytearray() + for offset in range(0, len(encrypted) - 7, 8): + plain += _tripledes_crypt(encrypted[offset : offset + 8], schedule) + + # 尾部不足 8 字节的分组原样保留(inflate 只看有效部分,与原文 set 不上剩余块一致) + plain += encrypted[len(plain) :] + + return _inflate(bytes(plain)).decode("utf-8", errors="replace") diff --git a/app/sdk/platforms/tx/singer.py b/app/sdk/platforms/tx/singer.py new file mode 100644 index 0000000..b3353c1 --- /dev/null +++ b/app/sdk/platforms/tx/singer.py @@ -0,0 +1,147 @@ +"""QQ音乐歌手(移植自 sdk/tx/singer.ts)。 + +上游歌手详情接口(musichallSinger.GetSingerDetail 等)已停用(104403),这里按原文的 +合成路径取信息: + * 歌手名 —— 从 GetSingerSongList 第一条曲目的 singer 数组里按 mid 反查(找不到取首位) + * 作品数 —— GetSingerSongList 的 totalNum + * 专辑数 —— GetAlbumList 的 total + * 头像/性别 —— 头像走固定 URL 规则(T001),性别接口已无可信来源,返回 null +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from app.sdk.platforms.tx.filter import album_img, music_info_item +from app.sdk.platforms.tx.utils import create_musicu_fetch + +SONG_LIST_MODULE = "musichall.song_list_server" +ALBUM_LIST_MODULE = "music.musichallAlbum.AlbumListServer" + + +def filter_song_list(raw: Any) -> list[dict[str, Any]]: + """GetSingerSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。""" + return [music_info_item((item or {}).get("songInfo")) for item in raw or []] + + +def filter_album_list(raw: Any) -> list[dict[str, Any]]: + return [ + { + "id": item.get("albumID"), + "mid": item.get("albumMid"), + "count": item.get("totalNum"), + "info": { + "name": item.get("albumName"), + "author": item.get("singerName"), + "img": album_img(item.get("albumMid")), + "desc": None, + }, + } + for item in raw or [] + if isinstance(item, dict) + ] + + +async def get_info(id: str) -> dict[str, Any]: + """歌手信息:歌曲数与专辑数并发取,任一失败整体失败(与原文 Promise.all 一致)。""" + song_body, album_body = await asyncio.gather( + create_musicu_fetch( + { + "req": { + "module": SONG_LIST_MODULE, + "method": "GetSingerSongList", + "param": {"singerMid": id, "order": 1, "begin": 0, "num": 1}, + } + } + ), + create_musicu_fetch( + { + "req": { + "module": ALBUM_LIST_MODULE, + "method": "GetAlbumList", + "param": {"singerMid": id, "order": 0, "begin": 0, "num": 1, "songNumTag": 0, "singerID": 0}, + } + } + ), + ) + song_req = song_body.get("req") or {} + album_req = album_body.get("req") or {} + if song_req.get("code") != 0 or album_req.get("code") != 0: + raise RuntimeError("get singer info faild.") + + song_data = song_req.get("data") or {} + album_data = album_req.get("data") or {} + # 详情接口停用:歌手名从歌曲条目反查 + first_song = (song_data.get("songList") or [{}])[0] or {} + singers = (first_song.get("songInfo") or {}).get("singer") or [] + self_info = next((s for s in singers if isinstance(s, dict) and s.get("mid") == id), None) + if self_info is None: + self_info = singers[0] if singers and isinstance(singers[0], dict) else {} + return { + "source": "tx", + "id": id, + "info": { + "name": self_info.get("name") or "", + "desc": None, + "avatar": f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{id}.jpg", + "gender": None, + }, + "count": { + "music": song_data.get("totalNum"), + "album": album_data.get("total"), + }, + } + + +async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]: + body = await create_musicu_fetch( + { + "req": { + "module": SONG_LIST_MODULE, + "method": "GetSingerSongList", + "param": {"singerMid": id, "order": 1, "begin": (page - 1) * limit, "num": limit}, + } + } + ) + req = body.get("req") or {} + if req.get("code") != 0: + raise RuntimeError("get singer song list faild.") + data = req.get("data") or {} + return { + "source": "tx", + "list": filter_song_list(data.get("songList")), + "limit": limit, + "page": page, + "total": data.get("totalNum"), + } + + +async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]: + body = await create_musicu_fetch( + { + "req": { + "module": ALBUM_LIST_MODULE, + "method": "GetAlbumList", + "param": { + "singerMid": id, + "order": 0, + "begin": (page - 1) * limit, + "num": limit, + "songNumTag": 0, + "singerID": 0, + }, + } + } + ) + req = body.get("req") or {} + if req.get("code") != 0: + raise RuntimeError("get singer album faild.") + data = req.get("data") or {} + return { + "source": "tx", + "list": filter_album_list(data.get("albumList")), + "limit": limit, + "page": page, + "total": data.get("total"), + } diff --git a/app/sdk/platforms/tx/song_list.py b/app/sdk/platforms/tx/song_list.py new file mode 100644 index 0000000..fac581c --- /dev/null +++ b/app/sdk/platforms/tx/song_list.py @@ -0,0 +1,348 @@ +"""QQ音乐歌单广场 + 歌单详情(移植自 sdk/tx/songList.ts)。 + +一个模块承载三件事(原文也是这样,路由按能力位分别用): + * song_list —— 分类标签与分类歌单列表(get_tag / get_hot_tag / get_list) + * playlist —— 歌单详情(get_list_detail)与歌单搜索(search) + * parse —— 歌单链接 → 数字 id 归一(get_list_id,含 302 短链兜底) + +分类歌单固定 36 条一页(limit_list),调用方要的 limit 在 playlist 层再切片。 +""" + +from __future__ import annotations + +import asyncio +import json +import re +from typing import Any +from urllib.parse import quote + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, format_singer_name +from app.sdk.platforms.tx.filter import build_types, song_img + +LIMIT_LIST = 36 +SUCCESS_CODE = 0 + +SORT_LIST: list[dict[str, Any]] = [ + {"name": "最热", "id": 5}, + {"name": "最新", "id": 2}, +] + +_HOT_TAG_HTML = re.compile(r'class="c_bg_link js_tag_item" data-id="\w+">.+?</a>') +_HOT_TAG = re.compile(r'data-id="(\w+)">(.+?)</a>') +# https://y.qq.com/n/yqq/playlist/7217720898.html +# https://i.y.qq.com/n2/m/share/details/taoge.html?...&id=7217720898&ADTAG=qfshare +_LIST_DETAIL_LINK = re.compile(r"/playlist/(\d+)") +_LIST_DETAIL_LINK2 = re.compile(r"id=(\d+)") + +_BASE_QUERY = ( + "loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0" +) +TAGS_URL = f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(json.dumps({'tags': {'method': 'get_all_categories', 'param': {'qq': ''}, 'module': 'playlist.PlaylistAllCategoriesServer'}}, ensure_ascii=False, separators=(',', ':')), safe='')}" +HOT_TAG_URL = "https://c.y.qq.com/node/pc/wk_v15/category_playlist.html" +LIST_DETAIL_URL = ( + "https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg" + "?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid={playlist_id}" + "&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0" + "&platform=yqq.json&needNewCode=0" +) +SEARCH_URL = ( + "http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist" + "?page_no={page}&num_per_page={limit}&format=json&query={query}" + "&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8" +) + +_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" + + +def get_list_url(sort_id: Any, tag_id: Any, page: int) -> str: + """分类歌单列表 URL:tagId 非空走分类内容接口,否则走「全部」广场接口。""" + if tag_id: + data: dict[str, Any] = { + "comm": {"cv": 1602, "ct": 20}, + "playlist": { + "method": "get_category_content", + "param": { + "titleid": int(tag_id), + "caller": "0", + "category_id": int(tag_id), + "size": LIMIT_LIST, + "page": page - 1, + "use_page": 1, + }, + "module": "playlist.PlayListCategoryServer", + }, + } + else: + data = { + "comm": {"cv": 1602, "ct": 20}, + "playlist": { + "method": "get_playlist_by_tag", + "param": { + "id": 10000000, + "sin": LIMIT_LIST * (page - 1), + "size": LIMIT_LIST, + "order": sort_id, + "cur_page": page, + }, + "module": "playlist.PlayListPlazaServer", + }, + } + payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")) + return f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(payload, safe='')}" + + +def filter_tag_info(raw_list: Any) -> list[dict[str, Any]]: + return [ + { + "name": group.get("group_name"), + "list": [ + { + "parent_id": group.get("group_id"), + "parent_name": group.get("group_name"), + "id": item.get("id"), + "name": item.get("name"), + "source": "tx", + } + for item in group.get("v_item") or [] + ], + } + for group in raw_list or [] + ] + + +def filter_info_hot_tag(html: Any) -> list[dict[str, Any]]: + tags: list[dict[str, Any]] = [] + if not isinstance(html, str): + return tags + for tag_html in _HOT_TAG_HTML.findall(html): + result = _HOT_TAG.search(tag_html) + if not result: + continue + try: + tag_id = int(result.group(1)) + except ValueError: + continue + tags.append({"id": tag_id, "name": result.group(2), "source": "tx"}) + return tags + + +def filter_list(data: dict[str, Any], page: int) -> dict[str, Any]: + """「全部」广场分支(get_playlist_by_tag)。""" + return { + "list": [ + { + "play_count": format_play_count(item.get("access_num")), + "id": str(item.get("tid")), + "author": (item.get("creator_info") or {}).get("nick"), + "name": item.get("title"), + "time": date_format(item["modify_time"] * 1000, "Y-M-D") if item.get("modify_time") else "", + "img": item.get("cover_url_medium"), + "total": len(item.get("song_ids") or []), + "desc": decode_name(item.get("desc")).replace("<br>", "\n"), + "source": "tx", + } + for item in data.get("v_playlist") or [] + ], + "total": data.get("total"), + "page": page, + "limit": LIMIT_LIST, + "source": "tx", + } + + +def filter_list2(data: dict[str, Any], page: int) -> dict[str, Any]: + """分类内容分支(get_category_content)。""" + content = data.get("content") or {} + rows: list[dict[str, Any]] = [] + for item in content.get("v_item") or []: + basic = item.get("basic") or {} + cover = basic.get("cover") or {} + rows.append( + { + "play_count": format_play_count(basic.get("play_cnt")), + "id": str(basic.get("tid")), + "author": (basic.get("creator") or {}).get("nick"), + "name": basic.get("title"), + "img": cover.get("medium_url") or cover.get("default_url"), + "desc": decode_name(basic.get("desc")).replace("<br>", "\n"), + "source": "tx", + } + ) + return { + "list": rows, + "total": content.get("total_cnt"), + "page": page, + "limit": LIMIT_LIST, + "source": "tx", + } + + +def filter_list_detail(raw_list: Any) -> list[dict[str, Any]]: + """歌单曲目 → SongInfo(比搜索结果多带 lrc/otherSource 两个空字段,与原文一致)。""" + songs: list[dict[str, Any]] = [] + for item in raw_list or []: + file = (item or {}).get("file") or {} + types, _types = build_types(file) + album = item.get("album") or {} + album_name = album.get("name") or "" + album_mid = album.get("mid") or "" + songs.append( + { + "singer": format_singer_name(item.get("singer"), "name"), + "name": item.get("title"), + "albumName": album_name, + "albumId": album_mid, + "source": "tx", + "interval": format_play_time(item.get("interval")), + "songId": item.get("id"), + "albumMid": album_mid, + "strMediaMid": file.get("media_mid"), + "songmid": item.get("mid"), + "img": song_img("" if album_name in ("", "空") else album_mid, item.get("singer")), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return songs + + +async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(TAGS_URL) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_tag(retry_num + 1) + return filter_tag_info(((body.get("tags") or {}).get("data") or {}).get("v_group")) + + +async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(HOT_TAG_URL) + if resp.status_code != 200: + return await get_hot_tag(retry_num + 1) + return filter_info_hot_tag(resp.body) + + +async def get_tags() -> dict[str, Any]: + """分类标签 + 热门标签。原 TS 用 Promise.all(任一失败整体失败), + 这里改为独立兜底:分类页少一个分组比整页 404 更有用。""" + tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag(), return_exceptions=True) + return { + "tags": [] if isinstance(tags, BaseException) else tags, + "hotTag": [] if isinstance(hot_tag, BaseException) else hot_tag, + "source": "tx", + } + + +async def get_list(sort_id: Any, tag_id: Any, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + if retry_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch(get_list_url(sort_id, tag_id, page)) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_list(sort_id, tag_id, page, retry_num + 1) + data = (body.get("playlist") or {}).get("data") or {} + return filter_list2(data, page) if tag_id else filter_list(data, page) + + +async def handle_parse_id(link: str, retry_num: int = 0) -> str: + """短链跟随:请求层不自动跟随重定向,手动读 Location(文案与原文一致:link try max num)。""" + if retry_num > 2: + raise RuntimeError("link try max num") + resp = await http_fetch(link) + if resp.status_code > 400: + return await handle_parse_id(link, retry_num + 1) + return resp.header("location") or link + + +async def get_list_id(playlist_id: str) -> str: + """链接/裸 id → 纯数字歌单 id。""" + if not re.search(r"[?&:/]", playlist_id): + return playlist_id + target = playlist_id + if not _LIST_DETAIL_LINK.search(target): + target = await handle_parse_id(target) + result = _LIST_DETAIL_LINK.search(target) + if not result: + result = _LIST_DETAIL_LINK2.search(target) + if not result: + raise RuntimeError("failed") + return result.group(1) + + +async def get_list_detail(playlist_id: str, retry_num: int = 0) -> dict[str, Any]: + """歌单详情(含全部曲目,不分页)。重试文案与原文一致:try max num。""" + if retry_num > 2: + raise RuntimeError("try max num") + bare = await get_list_id(playlist_id) + + resp = await http_fetch( + LIST_DETAIL_URL.format(playlist_id=quote(bare, safe="")), + headers={"Origin": "https://y.qq.com", "Referer": f"https://y.qq.com/n/yqq/playsquare/{bare}.html"}, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_list_detail(bare, retry_num + 1) + + cdlist = (body.get("cdlist") or [{}])[0] + songlist = cdlist.get("songlist") or [] + return { + "list": filter_list_detail(songlist), + "page": 1, + "limit": len(songlist) + 1, + "total": len(songlist), + "source": "tx", + "info": { + "name": cdlist.get("dissname"), + "img": cdlist.get("logo"), + "desc": decode_name(cdlist.get("desc")).replace("<br>", "\n"), + "author": cdlist.get("nickname"), + "play_count": format_play_count(cdlist.get("visitnum")), + }, + } + + +async def search(text: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + """歌单搜索。重试上限 5 次(文案与原文一致:max retry)。""" + if retry_num > 5: + raise RuntimeError("max retry") + resp = await http_fetch( + SEARCH_URL.format(page=page - 1, limit=limit, query=quote(text, safe="")), + headers={"User-Agent": _UA_IE, "Referer": "http://y.qq.com/portal/search.html"}, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != 0: + return await search(text, page, limit, retry_num + 1) + data = body.get("data") or {} + return { + "list": [ + { + "play_count": format_play_count(item.get("listennum")), + "id": str(item.get("dissid")), + "author": decode_name((item.get("creator") or {}).get("name")), + "name": decode_name(item.get("dissname")), + "time": date_format(item.get("createtime"), "Y-M-D"), + "img": item.get("imgurl"), + "total": item.get("song_count"), + "desc": decode_name(decode_name(item.get("introduction"))).replace("<br>", "\n"), + "source": "tx", + } + for item in data.get("list") or [] + ], + "limit": limit, + "total": data.get("sum"), + "source": "tx", + } + + +async def get_detail_page_url(playlist_id: str) -> str: + return f"https://y.qq.com/n/ryqq/playlist/{await get_list_id(playlist_id)}" + + +sort_list = SORT_LIST diff --git a/app/sdk/platforms/tx/user_playlist.py b/app/sdk/platforms/tx/user_playlist.py new file mode 100644 index 0000000..f5ec1d5 --- /dev/null +++ b/app/sdk/platforms/tx/user_playlist.py @@ -0,0 +1,292 @@ +"""QQ音乐个人歌单(移植自 sdk/tx/userPlaylist.ts)。 + +需登录 cookie(auth_store.get_tx_auth 返回的 cookie,须含 uin 类字段): +未登录 / cookie 里取不到 uin 时**抛出清晰错误**,不静默返回空列表(与原文一致)。 + +三路数据源,结果按 id 去重: + 1) fcg_user_created_diss → 自建歌单 + 2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单 + 3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上) + +虚拟条目 id 固定为 `profile:favorites`(与原文一致),其歌曲需凭 cookie 走同一接口拉取。 +""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import urlencode + +from app.sdk.auth_store import get_tx_auth +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_count + +_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" +FAVORITE_ID = "profile:favorites" +FAVORITE_NAME = "我喜欢的歌曲" + +_CREATED_URL = "https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss" +_PROFILE_ASSET_URL = "https://c.y.qq.com/fav/fcgi-bin/fcg_get_profile_order_asset.fcg" + +# 上游 normalizeQQUIN 的候选键,按序取第一个非空 +_UIN_KEYS = ( + "wxuin", + "uin", + "ptui_loginuin", + "luin", + "pt2gguin", + "superuin", + "p_uin", + "musicid", + "userid", +) + + +def cookie_value(cookie: str, key: str) -> str: + for part in cookie.split(";"): + name, sep, value = part.strip().partition("=") + if sep and name.strip() == key: + return value.strip() + return "" + + +def normalize_qq_uin(cookie: str) -> str: + """cookie → uin(去前导 `o` 与 `0`,与原文 normalizeQQUIN 一致)。""" + raw = "" + for key in _UIN_KEYS: + value = cookie_value(cookie, key) + if value: + raw = value + break + return raw.removeprefix("o").lstrip("0").strip() + + +def _playlist_link(playlist_id: str) -> str: + return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}" + + +def _normalize_cover(cover: Any) -> str: + value = str(cover or "").strip() + if value.startswith("//"): + return f"https:{value}" + if value.startswith("http://"): + return value.replace("http://", "https://", 1) + return value + + +def _make_summary( + playlist_id: str, + name: str, + *, + cover: Any = "", + track_count: Any = 0, + play_count: Any = 0, + creator: Any = "", + description: Any = "", + link: str | None = None, +) -> dict[str, Any]: + count = int(play_count or 0) + return { + "id": playlist_id, + "name": name, + "cover": _normalize_cover(cover), + "trackCount": int(track_count or 0), + "playCount": count, + "play_count": format_play_count(count), + "creator": str(creator or ""), + "description": str(description or ""), + "link": link or _playlist_link(playlist_id), + "source": "tx", + } + + +def _profile_asset_url(uin: str, reqtype: str, page: int, limit: int) -> str: + offset = (max(1, page) - 1) * limit + query = urlencode( + { + "format": "json", + "inCharset": "utf8", + "outCharset": "utf-8", + "platform": "yqq.json", + "needNewCode": "0", + "loginUin": uin, + "hostUin": "0", + "notice": "0", + "g_tk": "5381", + "ct": "20", + "cid": "205360956", + "userid": uin, + "reqtype": reqtype, + "sin": str(offset), + "ein": str(offset + limit - 1), + } + ) + return f"{_PROFILE_ASSET_URL}?{query}" + + +async def _fetch_json(url: str, cookie: str) -> dict[str, Any]: + resp = await http_fetch( + url, + headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Cookie": cookie}, + timeout=15000, + ) + body = resp.body if isinstance(resp.body, dict) else None + if resp.status_code != 200 or body is None: + raise RuntimeError("tx 个人歌单接口请求失败") + return body + + +async def _fetch_created(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]: + """自建歌单(fcg_user_created_diss)。id 优先级:dissid > tid > profile:dir:<dirid>。""" + query = urlencode( + { + "hostuin": uin, + "sin": str((page - 1) * limit), + "size": str(limit), + "format": "json", + "inCharset": "utf8", + "outCharset": "utf-8", + } + ) + body = await _fetch_json(f"{_CREATED_URL}?{query}", cookie) + if body.get("code") != 0: + raise RuntimeError(f"tx 自建歌单获取失败: {body.get('message') or ''} (code {body.get('code')})") + + data = body.get("data") or {} + rows: list[dict[str, Any]] = [] + for item in data.get("disslist") or []: + if not isinstance(item, dict): + continue + diss_id = int(item.get("dissid") or 0) + tid = int(item.get("tid") or 0) + dir_id = int(item.get("dirid") or 0) + if diss_id > 0: + playlist_id = str(diss_id) + elif tid > 0: + playlist_id = str(tid) + elif dir_id > 0: + playlist_id = f"profile:dir:{dir_id}" + else: + playlist_id = "" + name = str(item.get("diss_name") or item.get("title") or "").strip() + if not playlist_id or not name: + continue + rows.append( + _make_summary( + playlist_id, + name, + cover=item.get("diss_cover") or item.get("cover"), + track_count=item.get("song_count") or item.get("song_num") or item.get("song_cnt") or 0, + play_count=item.get("listen_num") or item.get("visitnum") or 0, + creator=uin, + description=item.get("diss_desc") or item.get("desc") or "", + ) + ) + for item in data.get("list") or []: + if not isinstance(item, dict): + continue + playlist_id = str(item.get("dissid") or "").strip() + name = str(item.get("dissname") or "").strip() + if not playlist_id or not name: + continue + rows.append( + _make_summary( + playlist_id, + name, + cover=item.get("imgurl"), + track_count=item.get("song_count") or item.get("song_num") or 0, + play_count=item.get("listennum") or 0, + creator=uin, + description=item.get("introduction") or "", + ) + ) + return rows + + +async def _fetch_collected(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]: + """收藏歌单(fcg_get_profile_order_asset reqtype=3)。code 非 0 时返回空列表(同原文)。""" + body = await _fetch_json(_profile_asset_url(uin, "3", page, limit), cookie) + if body.get("code") != 0: + return [] + rows: list[dict[str, Any]] = [] + for item in (body.get("data") or {}).get("cdlist") or []: + if not isinstance(item, dict): + continue + diss_id = int(item.get("dissid") or 0) + name = str(item.get("dissname") or "").strip() + if diss_id <= 0 or not name: + continue + rows.append( + _make_summary( + str(diss_id), + name, + cover=item.get("logo"), + track_count=item.get("songnum") or 0, + play_count=item.get("listennum") or 0, + creator=item.get("nickname") or item.get("uin") or uin, + ) + ) + return rows + + +async def _fetch_favorite_summary(cookie: str, uin: str) -> dict[str, Any] | None: + """「我喜欢的歌曲」摘要(reqtype=1,limit=1)。总数为 0 时返回 None。""" + body = await _fetch_json(_profile_asset_url(uin, "1", 1, 1), cookie) + if body.get("code") != 0: + return None + data = body.get("data") or {} + total = int(data.get("totalsong") or 0) + if total <= 0: + return None + first = ((data.get("songlist") or [{}])[0] or {}).get("data") or {} + album_mid = first.get("albummid") if isinstance(first, dict) else None + return _make_summary( + FAVORITE_ID, + FAVORITE_NAME, + cover=f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else "", + track_count=total, + creator=uin, + description="QQ音乐我喜欢的歌曲", + link="https://y.qq.com/n/ryqq/profile", + ) + + +async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]: + """个人歌单列表。未登录 / 缺 uin 直接抛错,不返回空列表。""" + auth = get_tx_auth() + cookie = str((auth or {}).get("cookie") or "").strip() + if not cookie: + raise RuntimeError("tx 个人歌单需要登录 cookie(请先登录 QQ音乐)") + uin = normalize_qq_uin(cookie) + if not uin: + raise RuntimeError("tx 个人歌单需要 cookie 中的 uin(当前 cookie 不含 uin/wxuin 等字段)") + + safe_page = max(1, int(page or 1)) + safe_limit = int(limit or 30) + if safe_limit <= 0: + safe_limit = 30 + if safe_limit > 100: + safe_limit = 100 + + rows: list[dict[str, Any]] = [] + seen: set[str] = set() + + def add(summary: dict[str, Any] | None) -> None: + if not summary: + return + playlist_id = summary.get("id") + if not playlist_id or not summary.get("name") or playlist_id in seen: + return + seen.add(playlist_id) + rows.append(summary) + + try: + add(await _fetch_favorite_summary(cookie, uin)) + except (RuntimeError, OSError, TimeoutError): + # 摘要失败不阻塞列表(与上游一致) + pass + for item in await _fetch_created(cookie, uin, safe_page, safe_limit): + add(item) + for item in await _fetch_collected(cookie, uin, safe_page, safe_limit): + add(item) + + return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "tx"} diff --git a/app/sdk/platforms/tx/utils/__init__.py b/app/sdk/platforms/tx/utils/__init__.py new file mode 100644 index 0000000..992e322 --- /dev/null +++ b/app/sdk/platforms/tx/utils/__init__.py @@ -0,0 +1,63 @@ +"""QQ音乐(tx)工具集 —— 签名通道与 zzc 签名。 + +源: sdk/tx/utils/index.ts + sdk/tx/utils/crypto.ts +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.tx.utils.crypto import zzc_sign + +MUSICU_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg" +# 原 TS 用两条不同 UA:musics.fcg 走客户端 UA,musicu.fcg 走旧 IE UA +UA_CLIENT = "QQMusic 14090508(android 12)" +UA_MUSICU = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)" + + +async def sign_request(data: dict[str, Any]) -> Any: + """musics.fcg 签名通道:sign 由 zzc_sign(JSON 串) 生成。 + + 签名与实际发送的 body 必须是同一个字符串:http_fetch 对 dict 会自行 + json.dumps(默认分隔符带空格),与这里的紧凑序列化不一致会导致服务端验签失败并回 + code 2000。因此统一序列化一次,并把该字符串原样作为 body 发送(str 走原样发送分支)。 + """ + text = _to_json(data) + sign = zzc_sign(text) + return await http_fetch( + f"https://u.y.qq.com/cgi-bin/musics.fcg?sign={sign}", + method="post", + headers={"User-Agent": UA_CLIENT}, + body=text, + ) + + +async def create_musicu_fetch(data: dict[str, Any], retry_num: int = 0) -> dict[str, Any]: + """musicu.fcg 通用请求,失败重试 3 次(文案与原文一致:try max num)。 + + 注意:comm 固定 uin=0,即便已登录也走匿名通道 —— 与原文一致(登录态只影响取流)。 + """ + if retry_num > 2: + raise RuntimeError("try max num") + + try: + resp = await http_fetch( + MUSICU_URL, + method="post", + headers={"User-Agent": UA_MUSICU}, + body={"comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859}, **data}, + ) + except (RuntimeError, OSError, TimeoutError): + return await create_musicu_fetch(data, retry_num + 1) + + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 0: + return await create_musicu_fetch(data, retry_num + 1) + return body + + +def _to_json(data: Any) -> str: + import json + + return json.dumps(data, ensure_ascii=False, separators=(",", ":")) diff --git a/app/sdk/platforms/tx/utils/crypto.py b/app/sdk/platforms/tx/utils/crypto.py new file mode 100644 index 0000000..7ebe094 --- /dev/null +++ b/app/sdk/platforms/tx/utils/crypto.py @@ -0,0 +1,56 @@ +"""QQ音乐 musics.fcg 签名(移植自 sdk/tx/utils/crypto.ts)。 + +算法:SHA1 摘要的十六进制串 → 按固定下标取两段字符 → 第三段用固定混淆表 +与摘要每字节异或后 base64(剔除 + / = 与反斜杠)→ 拼成 `zzc{part1}{b64}{part2}` 转小写。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import re + +PART_1_INDEXES = (23, 14, 6, 36, 16, 40, 7, 19) +PART_2_INDEXES = (16, 1, 32, 12, 19, 27, 8, 5) +SCRAMBLE_VALUES = ( + 89, + 39, + 179, + 150, + 218, + 82, + 58, + 252, + 177, + 52, + 186, + 123, + 120, + 64, + 242, + 133, + 143, + 161, + 121, + 179, +) +_B64_STRIP = re.compile(r"[\\/+=]") + + +def _pick_by_indexes(hash_hex: str, indexes: tuple[int, ...]) -> str: + """按固定下标取字符。 + + PART_1_INDEXES 含下标 40,而 SHA1 十六进制串正好 40 字符(合法下标 0–39)。 + TS 侧 `hash[40]` 取到 undefined,`join('')` 会把它并成空串;Python 直接下标会抛 + IndexError,因此这里显式跳过越界下标,保持与上游签名逐字节一致。 + """ + return "".join(hash_hex[index] for index in indexes if 0 <= index < len(hash_hex)) + + +def zzc_sign(text: str) -> str: + hash_hex = hashlib.sha1(text.encode()).hexdigest() + part1 = _pick_by_indexes(hash_hex, PART_1_INDEXES) + part2 = _pick_by_indexes(hash_hex, PART_2_INDEXES) + scrambled = bytes(value ^ int(hash_hex[i * 2 : i * 2 + 2], 16) for i, value in enumerate(SCRAMBLE_VALUES)) + part3 = _B64_STRIP.sub("", base64.b64encode(scrambled).decode()) + return f"zzc{part1}{part3}{part2}".lower() diff --git a/app/sdk/platforms/wy/__init__.py b/app/sdk/platforms/wy/__init__.py new file mode 100644 index 0000000..211b34e --- /dev/null +++ b/app/sdk/platforms/wy/__init__.py @@ -0,0 +1,61 @@ +"""网易音乐(wy)平台模块 —— 移植自 sdk/wy/index.ts + sdk/apis/wy.ts。 + +能力位与原实现对齐: + music_search / leaderboard / song_list / hot_search / comment / singer / + album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic / + get_music_info / get_music_detail_page_url +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms._base import Platform +from app.sdk.platforms.wy import ( + album, + comment, + hot_search, + leaderboard, + lyric, + music_search, + parse, + playlist, + singer, + song_list, + user_playlist, +) +from app.sdk.platforms.wy.music_info import get_music_info +from app.sdk.platforms.wy.pic import get_pic + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + return f"https://music.163.com/#/song?id={song_info.get('songmid')}" + + +async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + """取流入口。延迟导入 apis.wy:apis 模块反向依赖 platforms.wy.utils, + 模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块。""" + from app.sdk.apis import wy as wy_api + + return await wy_api.get_music_url(song_info, quality, retry_num) + + +MODULE = Platform( + id="wy", + 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, + user_playlist=user_playlist, + 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, +) diff --git a/app/sdk/platforms/wy/album.py b/app/sdk/platforms/wy/album.py new file mode 100644 index 0000000..ebb2e9e --- /dev/null +++ b/app/sdk/platforms/wy/album.py @@ -0,0 +1,172 @@ +"""网易云专辑(移植自 sdk/wy/album.ts,上游 music-lib/netease/album.go + fetchAlbumDetail)。 + +搜索:cloudsearch pc type=10(eapi 通道) +详情/曲目:weapi/v1/album/<id>(匿名可用,登录 cookie 可选) +链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink) +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_time, format_singer_name, size_formate +from app.sdk.platforms.wy.link import parse_netease_link +from app.sdk.platforms.wy.utils import eapi_request, weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + +ALBUM_LINK = "https://music.163.com/#/album?id={}" + + +def album_link(album_id: str) -> str: + return ALBUM_LINK.format(album_id) + + +def convert_song(item: dict[str, Any]) -> dict[str, Any]: + """网易云歌曲条目 → SongInfo(与 song_list.filter_list_detail 对齐)。 + + 这里只在尺寸为正数时声明档位(专辑接口不带 privilege,只能按资源块推断)。 + """ + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + + def push(kind: str, size_raw: Any) -> None: + size = size_formate(size_raw) if isinstance(size_raw, (int, float)) and size_raw > 0 else None + if not size: + return + types.append({"type": kind, "size": size}) + _types[kind] = {"size": size} + + push("128k", (item.get("l") or {}).get("size") if isinstance(item.get("l"), dict) else None) + push("320k", (item.get("h") or {}).get("size") if isinstance(item.get("h"), dict) else None) + push("flac", (item.get("sq") or {}).get("size") if isinstance(item.get("sq"), dict) else None) + push("flac24bit", (item.get("hr") or {}).get("size") if isinstance(item.get("hr"), dict) else None) + + album = item.get("al") if isinstance(item.get("al"), dict) else {} + if not album: + album = item.get("album") if isinstance(item.get("album"), dict) else {} + album = album if isinstance(album, dict) else {} + return { + "singer": format_singer_name(item.get("ar"), "name"), + "name": item.get("name") or "", + "albumName": album.get("name") or "", + "albumId": album.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or 0) / 1000), + "songmid": item.get("id"), + "img": album.get("picUrl") or "", + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + + +async def fetch_album_detail(album_id: str) -> dict[str, Any]: + """专辑详情 + 全量曲目。失败文案与原文一致(含上游 code)。""" + album_key = str(album_id or "").strip() + if not album_key: + raise RuntimeError("wy 专辑 id 为空") + + resp = await http_fetch( + f"https://music.163.com/weapi/v1/album/{album_key}", + method="post", + headers=weapi_headers(), + form=weapi({"csrf_token": ""}), + timeout=15000, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError(f"wy 专辑详情获取失败 (code {body.get('code', resp.status_code)})") + + album = body.get("album") or {} + artist = album.get("artist") or {} + artist_name = artist.get("name") or "、".join( + str(a.get("name")) for a in album.get("artists") or [] if isinstance(a, dict) and a.get("name") + ) + songs = [convert_song(song) for song in body.get("songs") or [] if isinstance(song, dict)] + return { + "source": "wy", + "info": { + "name": album.get("name") or "", + "img": album.get("picUrl") or "", + "desc": album.get("description") or album.get("briefDesc") or "", + "author": artist_name or "", + "albumId": str(album.get("id") or album_key), + "publishTime": album.get("publishTime"), + "company": album.get("company") or "", + }, + "list": songs, + "total": int(album.get("size") or 0) or len(songs), + } + + +async def get_album_detail(album_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """专辑详情(本地按 page/limit 切片)。""" + detail = await fetch_album_detail(album_id) + start = max(0, (page - 1) * limit) + return { + "source": "wy", + "info": detail["info"], + "list": detail["list"][start : start + limit], + "page": page, + "limit": limit, + "total": detail["total"] or len(detail["list"]), + } + + +async def get_album_songs(album_id: str) -> list[dict[str, Any]]: + return (await fetch_album_detail(album_id))["list"] + + +async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]: + """专辑搜索(eapi cloudsearch pc type=10)。""" + resp = await eapi_request( + "/api/cloudsearch/pc", + {"s": str(keyword or ""), "type": 10, "limit": limit, "total": page == 1, "offset": limit * (page - 1)}, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError(f"wy 专辑搜索失败 (code {body.get('code', resp.status_code)})") + + result = body.get("result") or {} + rows: list[dict[str, Any]] = [] + for item in result.get("albums") or []: + if not isinstance(item, dict): + continue + id_ = str(item.get("id") or "").strip() + if not id_: + continue + artist = item.get("artist") or {} + artist_name = artist.get("name") or "、".join( + str(a.get("name")) for a in item.get("artists") or [] if isinstance(a, dict) and a.get("name") + ) + rows.append( + { + "id": id_, + "name": item.get("name") or "", + "cover": item.get("picUrl") or "", + "trackCount": int(item.get("size") or 0), + "creator": artist_name, + "description": item.get("description") or item.get("briefDesc") or "", + "link": album_link(id_), + "type": "album", + "company": item.get("company") or "", + "publishTime": item.get("publishTime"), + "source": "wy", + } + ) + total = int(result.get("albumCount") or 0) or len(rows) + return {"list": rows, "page": page, "limit": limit, "total": total, "source": "wy"} + + +async def parse_album_link(link: str) -> dict[str, Any]: + """专辑链接解析(裸数字 id 兜底,上游 ParseAlbum 只接受链接)。""" + raw = str(link or "").strip() + if raw.isdigit(): + return await get_album_detail(raw) + kind, id_ = parse_netease_link(raw) + if kind != "album": + raise RuntimeError("无效的网易云专辑链接") + return await get_album_detail(id_) diff --git a/app/sdk/platforms/wy/comment.py b/app/sdk/platforms/wy/comment.py new file mode 100644 index 0000000..b6166a6 --- /dev/null +++ b/app/sdk/platforms/wy/comment.py @@ -0,0 +1,258 @@ +"""网易云评论(移植自 sdk/wy/comment.ts)。 + +网易云的评论接口是游标式(orderType + cursor + offset),不是页码式。原实现用 +`cursor_tools` 缓存每首歌最近一次的游标:向后翻页用旧游标 + offset,向前翻页用 orderType=0, +重刷同一页则回退到 prevCursor。这里逐行保留该逻辑,否则翻页会跳条。 + +注意请求头里的 `Refere` 是原实现的拼写,上游不校验该头,保留以免引入行为差异。 +""" + +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 +from app.sdk.platforms.wy.utils import weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + +EMOJIS: list[tuple[str, str]] = [ + ("大笑", "😃"), + ("可爱", "😊"), + ("憨笑", "☺️"), + ("色", "😍"), + ("亲亲", "😙"), + ("惊恐", "😱"), + ("流泪", "😭"), + ("亲", "😚"), + ("呆", "😳"), + ("哀伤", "😔"), + ("呲牙", "😁"), + ("吐舌", "😝"), + ("撇嘴", "😒"), + ("怒", "😡"), + ("奸笑", "😏"), + ("汗", "😓"), + ("痛苦", "😖"), + ("惶恐", "😰"), + ("生病", "😨"), + ("口罩", "😷"), + ("大哭", "😂"), + ("晕", "😵"), + ("发怒", "👿"), + ("开心", "😄"), + ("鬼脸", "😜"), + ("皱眉", "😞"), + ("流感", "😢"), + ("爱心", "❤️"), + ("心碎", "💔"), + ("钟情", "💘"), + ("星星", "⭐️"), + ("生气", "💢"), + ("便便", "💩"), + ("强", "👍"), + ("弱", "👎"), + ("拜", "🙏"), + ("牵手", "👫"), + ("跳舞", "👯‍♀️"), + ("禁止", "🙅‍♀️"), + ("这边", "💁‍♀️"), + ("爱意", "💏"), + ("示爱", "👩‍❤️‍👨"), + ("嘴唇", "👄"), + ("狗", "🐶"), + ("猫", "🐱"), + ("猪", "🐷"), + ("兔子", "🐰"), + ("小鸡", "🐤"), + ("公鸡", "🐔"), + ("幽灵", "👻"), + ("圣诞", "🎅"), + ("外星", "👽"), + ("钻石", "💎"), + ("礼物", "🎁"), + ("男孩", "👦"), + ("女孩", "👧"), + ("蛋糕", "🎂"), + ("18", "🔞"), + ("圈", "⭕"), + ("叉", "❌"), +] + + +def apply_emoji(text: str) -> str: + for name, emoji in EMOJIS: + text = text.replace(f"[{name}]", emoji) + return text + + +class _CursorTools: + """按歌曲缓存游标。原实现是模块级单例对象,这里用类实例保持同一语义。""" + + def __init__(self) -> None: + self.cache: dict[str, dict[str, Any]] = {} + + def get_cursor(self, id_: str, page: int, limit: int) -> dict[str, Any]: + data = self.cache.setdefault(id_, {}) + order_type: int | None = None + cursor: int | None = None + offset: int | None = None + + if page == 1: + data["page"] = 1 + cursor = int(time.time() * 1000) + data["cursor"] = cursor + data["prevCursor"] = cursor + order_type = 1 + offset = 0 + elif data.get("page"): + cursor = data.get("cursor") + if page > data["page"]: + order_type = 1 + offset = (page - data["page"] - 1) * limit + elif page < data["page"]: + order_type = 0 + offset = (data["page"] - page - 1) * limit + else: + cursor = data.get("prevCursor") + data["cursor"] = cursor + offset = data.get("offset") + order_type = data.get("orderType") + + return {"orderType": order_type, "cursor": cursor, "offset": offset} + + def set_cursor(self, id_: str, cursor: Any, order_type: Any, offset: Any, page: int) -> None: + data = self.cache.setdefault(id_, {}) + data["prevCursor"] = data.get("cursor") + data["cursor"] = cursor + data["orderType"] = order_type + data["offset"] = offset + data["page"] = page + + +cursor_tools = _CursorTools() + + +def filter_comment(raw_list: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in raw_list or []: + if not isinstance(item, dict): + continue + user = item.get("user") or {} + raw_time = item.get("time") + content = item.get("content") + data = { + "id": item.get("commentId"), + "text": apply_emoji(str(content)) if content else "", + "time": raw_time or "", + "timeStr": date_format2(raw_time) if raw_time else "", + "location": (item.get("ipLocation") or {}).get("location"), + "userName": user.get("nickname"), + "avatar": user.get("avatarUrl"), + "userId": user.get("userId"), + "likedCount": item.get("likedCount"), + "reply": [], + } + + replied = item.get("beReplied") or [] + reply_data = replied[0] if replied else None + if not isinstance(reply_data, dict): + rows.append(data) + continue + reply_user = reply_data.get("user") or {} + reply_content = reply_data.get("content") + rows.append( + { + "id": item.get("commentId"), + "rootId": reply_data.get("beRepliedCommentId"), + "text": apply_emoji(str(reply_content)) if reply_content else "", + "time": item.get("time"), + "timeStr": None, + "location": (reply_data.get("ipLocation") or {}).get("location"), + "userName": reply_user.get("nickname"), + "avatar": reply_user.get("avatarUrl"), + "userId": reply_user.get("userId"), + "likedCount": None, + "reply": [data], + } + ) + return rows + + +def _max_page(total: int, limit: int) -> int: + return 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]: + """歌曲评论。失败文案与原文一致:获取评论失败。""" + song_mid = str(song_info.get("songmid")) + rid = f"R_SO_4_{song_mid}" + cursor_info = cursor_tools.get_cursor(song_mid, page, limit) + + resp = await http_fetch( + "https://music.163.com/weapi/comment/resource/comments/get", + method="post", + headers=weapi_headers(with_cookie=False), + form=weapi( + { + "cursor": cursor_info["cursor"], + "offset": cursor_info["offset"], + "orderType": cursor_info["orderType"], + "pageNo": page, + "pageSize": limit, + "rid": rid, + "threadId": rid, + } + ), + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError("获取评论失败") + + data = body.get("data") or {} + cursor_tools.set_cursor(song_mid, data.get("cursor"), cursor_info["orderType"], cursor_info["offset"], page) + total = int(data.get("totalCount") or 0) + return { + "source": "wy", + "comments": filter_comment(data.get("comments")), + "total": data.get("totalCount"), + "page": page, + "limit": limit, + "maxPage": _max_page(total, limit) or 1, + } + + +async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]: + """热门评论。失败文案与原文一致:获取热门评论失败。""" + song_mid = str(song_info.get("songmid")) + rid = f"R_SO_4_{song_mid}" + page = page - 1 + + resp = await http_fetch( + f"https://music.163.com/weapi/v1/resource/hotcomments/{rid}", + method="post", + headers=weapi_headers(with_cookie=False), + form=weapi( + { + "rid": rid, + "limit": limit, + "offset": limit * page, + "beforeTime": str(int(time.time() * 1000)), + } + ), + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError("获取热门评论失败") + + total = int(body.get("total") or 0) + return { + "source": "wy", + "comments": filter_comment(body.get("hotComments")), + "total": total, + "page": page, + "limit": limit, + "maxPage": _max_page(total, limit) or 1, + } diff --git a/app/sdk/platforms/wy/hot_search.py b/app/sdk/platforms/wy/hot_search.py new file mode 100644 index 0000000..e83f05b --- /dev/null +++ b/app/sdk/platforms/wy/hot_search.py @@ -0,0 +1,25 @@ +"""网易云热搜词(移植自 sdk/wy/hotSearch.ts)。""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms.wy.utils import eapi_request + + +def filter_list(raw_list: Any) -> list[str]: + return [str(item.get("searchWord")) for item in raw_list or [] if isinstance(item, dict) and item.get("searchWord")] + + +async def get_list(retry_num: int = 0) -> dict[str, Any]: + """热搜词列表。失败文案与原文一致:获取热搜词失败。""" + if retry_num > 2: + raise RuntimeError("try max num") + + resp = await eapi_request("/api/search/chart/detail", {"id": "HOT_SEARCH_SONG#@#"}) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError("获取热搜词失败") + + data = body.get("data") or {} + return {"source": "wy", "list": filter_list(data.get("itemList"))} diff --git a/app/sdk/platforms/wy/leaderboard.py b/app/sdk/platforms/wy/leaderboard.py new file mode 100644 index 0000000..9fcc9df --- /dev/null +++ b/app/sdk/platforms/wy/leaderboard.py @@ -0,0 +1,117 @@ +"""网易云榜单(移植自 sdk/wy/leaderboard.ts)。 + +榜单列表是硬编码的 top_list(原实现的 get_boards 直接返回它,不再请求 weapi/toplist); +详情走 weapi/v3/playlist/detail 拿 trackIds,再用 song/detail 批量补全曲目。 + +id 必须是 `wy__<bangid>` 形态:路由 `_board_handler` 会剥掉 `wy__` 前缀得到裸 bangid, +前端拿到的 id 也只有带前缀时才和详情接口闭环。 +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.wy import music_detail +from app.sdk.platforms.wy.utils import weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + +LIMIT = 100000 + +TOP_LIST: list[dict[str, str]] = [ + {"id": "wy__19723756", "name": "飙升榜", "bangid": "19723756"}, + {"id": "wy__3779629", "name": "新歌榜", "bangid": "3779629"}, + {"id": "wy__2884035", "name": "原创榜", "bangid": "2884035"}, + {"id": "wy__3778678", "name": "热歌榜", "bangid": "3778678"}, + {"id": "wy__991319590", "name": "说唱榜", "bangid": "991319590"}, + {"id": "wy__71384707", "name": "古典榜", "bangid": "71384707"}, + {"id": "wy__1978921795", "name": "电音榜", "bangid": "1978921795"}, + {"id": "wy__5453912201", "name": "黑胶VIP爱听榜", "bangid": "5453912201"}, + {"id": "wy__71385702", "name": "ACG榜", "bangid": "71385702"}, + {"id": "wy__745956260", "name": "韩语榜", "bangid": "745956260"}, + {"id": "wy__10520166", "name": "国电榜", "bangid": "10520166"}, + {"id": "wy__180106", "name": "UK排行榜周榜", "bangid": "180106"}, + {"id": "wy__60198", "name": "美国Billboard榜", "bangid": "60198"}, + {"id": "wy__3812895", "name": "Beatport全球电子舞曲榜", "bangid": "3812895"}, + {"id": "wy__21845217", "name": "KTV唛榜", "bangid": "21845217"}, + {"id": "wy__60131", "name": "日本Oricon榜", "bangid": "60131"}, + {"id": "wy__2809513713", "name": "欧美热歌榜", "bangid": "2809513713"}, + {"id": "wy__2809577409", "name": "欧美新歌榜", "bangid": "2809577409"}, + {"id": "wy__27135204", "name": "法国 NRJ Vos Hits 周榜", "bangid": "27135204"}, + {"id": "wy__3001835560", "name": "ACG动画榜", "bangid": "3001835560"}, + {"id": "wy__3001795926", "name": "ACG游戏榜", "bangid": "3001795926"}, + {"id": "wy__3001890046", "name": "ACG VOCALOID榜", "bangid": "3001890046"}, + {"id": "wy__3112516681", "name": "中国新乡村音乐排行榜", "bangid": "3112516681"}, + {"id": "wy__5059644681", "name": "日语榜", "bangid": "5059644681"}, + {"id": "wy__5059633707", "name": "摇滚榜", "bangid": "5059633707"}, + {"id": "wy__5059642708", "name": "国风榜", "bangid": "5059642708"}, + {"id": "wy__5338990334", "name": "潜力爆款榜", "bangid": "5338990334"}, + {"id": "wy__5059661515", "name": "民谣榜", "bangid": "5059661515"}, + {"id": "wy__6688069460", "name": "听歌识曲榜", "bangid": "6688069460"}, + {"id": "wy__6723173524", "name": "网络热歌榜", "bangid": "6723173524"}, + {"id": "wy__6732051320", "name": "俄语榜", "bangid": "6732051320"}, + {"id": "wy__6732014811", "name": "越南语榜", "bangid": "6732014811"}, + {"id": "wy__6886768100", "name": "中文DJ榜", "bangid": "6886768100"}, + {"id": "wy__6939992364", "name": "俄罗斯top hit流行音乐榜", "bangid": "6939992364"}, + {"id": "wy__7095271308", "name": "泰语榜", "bangid": "7095271308"}, + {"id": "wy__7356827205", "name": "BEAT排行榜", "bangid": "7356827205"}, + {"id": "wy__7325478166", "name": "编辑推荐榜VOL.44 天才女子摇滚乐队boygenius剖白卑微心迹", "bangid": "7325478166"}, + {"id": "wy__7603212484", "name": "LOOK直播歌曲榜", "bangid": "7603212484"}, + {"id": "wy__7775163417", "name": "赏音榜", "bangid": "7775163417"}, + {"id": "wy__7785123708", "name": "黑胶VIP新歌榜", "bangid": "7785123708"}, + {"id": "wy__7785066739", "name": "黑胶VIP热歌榜", "bangid": "7785066739"}, + {"id": "wy__7785091694", "name": "黑胶VIP爱搜榜", "bangid": "7785091694"}, +] + + +def filter_boards_data(raw_list: Any) -> list[dict[str, str]]: + """weapi/toplist 响应 → 榜单列表(原实现的 filterBoardsData;当前 get_boards 不走它)。""" + return [ + {"id": f"wy__{board['id']}", "name": str(board.get("name")), "bangid": str(board["id"])} + for board in raw_list or [] + if isinstance(board, dict) and board.get("id") is not None + ] + + +async def get_boards() -> dict[str, Any]: + """榜单列表。原实现直接返回硬编码 top_list,不请求上游。""" + return {"list": TOP_LIST, "source": "wy"} + + +async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]: + """榜单详情。失败递归重试最多 6 次,超出抛 try max num(文案与原文一致)。""" + retry_num += 1 + if retry_num > 6: + raise RuntimeError("try max num") + + resp = await http_fetch( + "https://music.163.com/weapi/v3/playlist/detail", + method="post", + headers=weapi_headers(with_cookie=False), + form=weapi({"id": bangid, "n": LIMIT, "p": 1}), + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + # 让出事件循环:上游持续 5xx 时递归重试不应霸占单线程事件循环 + await asyncio.sleep(0) + return await get_list(bangid, page, retry_num) + + playlist = body.get("playlist") or {} + ids = [track.get("id") for track in playlist.get("trackIds") or [] if isinstance(track, dict)] + try: + detail = await music_detail.get_list(ids) + except Exception as err: + print(f"[wy] 榜单歌曲详情获取失败将重试({retry_num + 1}): {err}") + if str(err) == "try max num": + raise + await asyncio.sleep(0) + return await get_list(bangid, page, retry_num) + + rows = detail["list"] + return {"total": len(rows), "list": rows, "limit": LIMIT, "page": page, "source": "wy"} + + +def get_detail_page_url(bangid: str) -> str: + bare = bangid.replace("wy__", "") if isinstance(bangid, str) else bangid + return f"https://music.163.com/#/discover/toplist?id={bare}" diff --git a/app/sdk/platforms/wy/link.py b/app/sdk/platforms/wy/link.py new file mode 100644 index 0000000..f7a24e4 --- /dev/null +++ b/app/sdk/platforms/wy/link.py @@ -0,0 +1,84 @@ +"""网易云链接解析(移植自 sdk/wy/link.ts,上游 music-lib/netease/netease.go 的 parseNeteaseLink)。 + +支持: + 歌曲 https://music.163.com/#/song?id=29732995 /song?id= / /song/29732995 + 专辑 https://music.163.com/#/album?id=32311 /album?id= / /album/32311 + 歌单 https://music.163.com/#/playlist?id=7507256008 /playlist?id= / /playlist/<id>/<n>/... +裸数字 id 由各模块自行兜底(上游 Parse 不接受裸 id)。 + +顺序上依次尝试原链接、path+query、hash 片段 —— 网页版把真实路径放在 # 后面, +因此必须逐候选尝试,只看 pathname 会漏掉 `#/song?id=` 这类主流形态。 +""" + +from __future__ import annotations + +import re +from typing import Any, Literal +from urllib.parse import parse_qs, urljoin, urlparse + +NeteaseLinkKind = Literal["song", "album", "playlist"] + +BASE = "https://music.163.com/" +_KINDS = ("song", "album", "playlist") +_DIGITS = re.compile(r"^\d+$") + + +def _is_digits(value: str) -> bool: + return value != "" and _DIGITS.match(value) is not None + + +def _parse_candidate(candidate: str) -> tuple[str, str]: + """解析单个候选串(JS 需给相对候选补 base;Go 的 url.Parse 直接接受相对路径)。""" + try: + parsed = urlparse(urljoin(BASE, candidate)) + except ValueError: + return "", "" + + kind = "" + segments = [seg for seg in parsed.path.lower().split("/") if seg] + # 取最后一个出现的类型段:/playlist/xxx 之类多余段不影响判断 + for segment in segments: + if segment in _KINDS: + kind = segment + + id_ = parse_qs(parsed.query).get("id", [""])[0] + if not _is_digits(id_): + id_ = "" + + if not kind and len(segments) >= 2: + last, prev = segments[-1], segments[-2] + if _is_digits(last) and prev in _KINDS: + kind, id_ = prev, last + + if not kind or not id_: + return "", "" + return kind, id_ + + +def parse_netease_link(link: Any) -> tuple[NeteaseLinkKind, str]: + """链接 → (类型, id)。无法识别时抛 ValueError(文案与原文一致)。""" + raw = str(link or "").strip() + candidates = [raw] + + try: + parsed = urlparse(urljoin(BASE, raw)) + except ValueError: + parsed = None + if parsed is not None: + if parsed.path and parsed.path != "/": + candidates.append(parsed.path + (f"?{parsed.query}" if parsed.query else "")) + fragment = re.sub(r"^#!?", "", parsed.fragment).strip() + if fragment: + candidates.append(fragment) + + for candidate in candidates: + kind, id_ = _parse_candidate(candidate) + if kind and id_: + return kind, id_ # type: ignore[return-value] + raise ValueError("无效的网易云链接") + + +def bare_id(link: Any) -> str: + """裸 id 兜底:非 URL 且全数字时视为对应类型的 id。""" + raw = str(link or "").strip() + return raw if _DIGITS.match(raw) else "" diff --git a/app/sdk/platforms/wy/lyric.py b/app/sdk/platforms/wy/lyric.py new file mode 100644 index 0000000..e06138b --- /dev/null +++ b/app/sdk/platforms/wy/lyric.py @@ -0,0 +1,247 @@ +"""网易云歌词(移植自 sdk/wy/lyric.ts,参考 Binaryify/NeteaseCloudMusicApi 的 module/lyric_new.js)。 + +上游返回 yrc(逐字)+ lrc(逐行)+ 各语种翻译/rlrc。逐字歌词的时间标签是 +`[起始ms,时长ms](词起始ms,词时长ms,0)词` 形态,需要转换成标准 LRC + 富文本 `<偏移,时长>` 标签。 +""" + +from __future__ import annotations + +import json +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.wy.utils import UA_LINUX_CHROME +from app.sdk.platforms.wy.utils.crypto import eapi + +RX_INFO = re.compile(r'^{"') +RX_LINE_TIME = re.compile(r"^\[(\d+),\d+\]") +RX_WORD_TIME = re.compile(r"\(\d+,\d+,\d+\)") +RX_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+,\d+\))") +RX_WORD_ITEM = re.compile(r"\((\d+),(\d+),\d+\)") + +LYRIC_HOST = "https://interface3.music.163.com/eapi/song/lyric/v1" + + +def ms_format(time_ms: Any) -> str: + """毫秒 → `[mm:ss.SSS]`。非数字返回空串(原实现用 Number.isNaN 判定)。""" + try: + total = int(float(time_ms)) + except (TypeError, ValueError): + return "" + if total < 0: + return "" + ms = total % 1000 + seconds = total // 1000 + # 与原实现一致:毫秒不补齐(JS 的 `timeMs % 1000` 直接插值) + return f"[{seconds // 60:02d}:{seconds % 60:02d}.{ms}]" + + +def parse_lyric(lines: list[str]) -> dict[str, str]: + yrc_lines: list[str] = [] + lrc_lines: list[str] = [] + + for raw_line in lines: + line = raw_line.strip() + match = RX_LINE_TIME.match(line) + if not match: + if line.startswith("[offset"): + yrc_lines.append(line) + lrc_lines.append(line) + continue + + start_ms = int(match.group(1)) + start_str = ms_format(start_ms) + if not start_str: + continue + + words = RX_LINE_TIME.sub("", line) + lrc_lines.append(start_str + RX_WORD_TIME_ALL.sub("", words)) + + word_times = RX_WORD_TIME_ALL.findall(words) + if not word_times: + continue + tags = [] + for chunk in word_times: + item = RX_WORD_ITEM.search(chunk) + if not item: + continue + tags.append(f"<{max(int(item.group(1)) - start_ms, 0)},{item.group(2)}>") + word_arr = RX_WORD_TIME.split(words)[1:] # 首段是第一个时间标签前的内容 + new_words = "".join( + f"{tag}{word_arr[index] if index < len(word_arr) else ''}" for index, tag in enumerate(tags) + ) + yrc_lines.append(f"{start_str}{new_words}") + + return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)} + + +def parse_header_info(text: Any) -> list[str] | None: + """清洗歌词文本:去掉 \\r,把 `{"t":ms,"c":[{"tx":...}]}` 形式的逐字行转成标准 LRC 行。""" + if text is None: + return None + raw = str(text).strip().replace("\r", "") + if not raw: + return None + out: list[str] = [] + for line in raw.split("\n"): + if not RX_INFO.match(line): + out.append(line) + continue + try: + info = json.loads(line) + except (ValueError, TypeError): + out.append("") + continue + tag = ms_format(info.get("t")) + if not tag: + out.append("") + continue + out.append(tag + "".join(str(chunk.get("tx", "")) for chunk in info.get("c") or [])) + return out + + +def get_intv(interval: Any) -> int: + """`mm:ss.SSS`(或 `hh:mm:ss.SSS`)→ 毫秒。""" + if not interval: + return 0 + text = str(interval) + if "." not in text: + text += ".0" + parts = re.split(r"[:.]", text) + while len(parts) < 3: + parts.insert(0, "0") + minute, second, milli = parts[0], parts[1], parts[2] + + def to_int(value: str) -> int: + try: + return int(value) + except ValueError: + return 0 + + return to_int(minute) * 3_600_000 + to_int(second) * 1000 + to_int(milli) + + +def fix_time_tag(lrc: str, target_lrc: str) -> str: + """把 target 的文本按时间接近(<100ms)对齐到 lrc 的时间标签上。""" + rx_time = re.compile(r"^\[([\d:.]+)\]") + lrc_lines = lrc.split("\n") + new_lrc: list[str] = [] + temp: list[str] = [] + + for line in target_lrc.split("\n"): + match = rx_time.match(line) + if not match: + continue + words = rx_time.sub("", line) + if not words.strip(): + continue + t1 = get_intv(match.group(1)) + + while lrc_lines: + lrc_line = lrc_lines.pop(0) + lrc_match = rx_time.match(lrc_line) + if not lrc_match: + continue + t2 = get_intv(lrc_match.group(1)) + if abs(t1 - t2) < 100: + merged = rx_time.sub(lrc_match.group(0), line).strip() + if not merged: + continue + new_lrc.append(merged) + break + temp.append(lrc_line) + lrc_lines = [*temp, *lrc_lines] + temp = [] + + return "\n".join(new_lrc) + + +def parse(y_lrc: Any, yt_lrc: Any, yr_lrc: Any, lrc: Any, tlrc: Any, rlrc: Any) -> dict[str, str]: + """优先用逐字歌词(yrc)合成,缺失时退回普通 LRC。""" + info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""} + if y_lrc: + lines = parse_header_info(y_lrc) + if lines: + result = parse_lyric(lines) + if yt_lrc: + t_lines = parse_header_info(yt_lrc) + if t_lines: + info["tlyric"] = fix_time_tag(result["lyric"], "\n".join(t_lines)) + if yr_lrc: + r_lines = parse_header_info(yr_lrc) + if r_lines: + info["rlyric"] = fix_time_tag(result["lyric"], "\n".join(r_lines)) + + headers = "\n".join(line for line in lines if re.match(r"^\[[\d:.]+\]", line)) + info["lyric"] = f"{headers}\n{result['lyric']}" + info["yrc"] = result["yrc"] + return info + + for key, value in (("lyric", lrc), ("tlyric", tlrc), ("rlyric", rlrc)): + if not value: + continue + parsed_lines = parse_header_info(value) + if parsed_lines: + info[key] = "\n".join(parsed_lines) + return info + + +def fix_time_label(lrc: Any, tlrc: Any = None, romalrc: Any = None) -> tuple[Any, Any, Any]: + """修正 `[mm:ss:SS]` 这类把毫秒写成第二冒号的错误时间标签(社区通行做法)。""" + if not lrc: + return lrc, tlrc, romalrc + text = str(lrc) + new_lrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", text) + new_tlrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", tlrc) if isinstance(tlrc, str) else tlrc + if new_lrc != text or new_tlrc != tlrc: + lrc, tlrc = new_lrc, new_tlrc + if isinstance(romalrc, str): + romalrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2,3})\]", r"[\1.\2]", romalrc) + romalrc = re.sub(r"\[(\d{2}:\d{2}\.\d{2})0\]", r"[\1]", romalrc) + return lrc, tlrc, romalrc + + +def _pick_lyric(block: Any) -> Any: + return block.get("lyric") if isinstance(block, dict) else None + + +async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]: + """取歌词。原文失败文案:Get lyric failed。""" + resp = await http_fetch( + LYRIC_HOST, + method="post", + headers={"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"}, + form=eapi( + "/api/song/lyric/v1", + { + "id": song_info.get("songmid"), + "cp": False, + "tv": 0, + "lv": 0, + "rv": 0, + "kv": 0, + "yv": 0, + "ytv": 0, + "yrv": 0, + }, + ), + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != 200 or not _pick_lyric(body.get("lrc")): + raise RuntimeError("Get lyric failed") + + lrc, tlrc, romalrc = fix_time_label( + _pick_lyric(body.get("lrc")), _pick_lyric(body.get("tlyric")), _pick_lyric(body.get("romalrc")) + ) + info = parse( + _pick_lyric(body.get("yrc")), + _pick_lyric(body.get("ytlrc")), + _pick_lyric(body.get("yromalrc")), + lrc, + tlrc, + romalrc, + ) + if not info["lyric"]: + raise RuntimeError("Get lyric failed") + return info diff --git a/app/sdk/platforms/wy/music_detail.py b/app/sdk/platforms/wy/music_detail.py new file mode 100644 index 0000000..9807551 --- /dev/null +++ b/app/sdk/platforms/wy/music_detail.py @@ -0,0 +1,141 @@ +"""歌曲详情与音质档位换算(移植自 sdk/wy/musicDetail.ts)。 + +上游 weapi/v3/song/detail 返回 songs + privileges,音质档位由 privilege.maxbr/maxBrLevel 决定, +档位声明顺序与 TS 版一致(flac24bit 先 push,switch 里依次追加,最后 reverse)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_time, size_formate +from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + + +def _size_of(block: Any) -> str | None: + """{size: N} → '1.23 MB';缺块返回 None(与 TS 的 `item.x ? sizeFormate(item.x.size) : null` 一致)。""" + if isinstance(block, dict) and block.get("size"): + return size_formate(block["size"]) + return None + + +def build_types(item: dict[str, Any], privilege: dict[str, Any], *, flac_size: bool = True) -> tuple[list[dict], dict]: + """按 privilege 生成 types/_types。`flac_size=False` 用于歌单详情(原实现那里 flac 的 size 恒为 null)。""" + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + + if privilege.get("maxBrLevel") == "hires": + size = _size_of(item.get("hr")) + types.append({"type": "flac24bit", "size": size}) + _types["flac24bit"] = {"size": size} + + maxbr = privilege.get("maxbr") + # 无 break 的 switch 直落是有意为之:高码率同时声明向下兼容的档位 + if maxbr in (999000, 320000, 192000, 128000): + if maxbr == 999000: + size = _size_of(item.get("sq")) if flac_size else None + types.append({"type": "flac", "size": size}) + _types["flac"] = {"size": size} + if maxbr in (999000, 320000): + size = _size_of(item.get("h")) + types.append({"type": "320k", "size": size}) + _types["320k"] = {"size": size} + size = _size_of(item.get("l")) + types.append({"type": "128k", "size": size}) + _types["128k"] = {"size": size} + + types.reverse() + return types, _types + + +def _match_privilege(privileges: list[dict[str, Any]], item: dict[str, Any], index: int) -> dict[str, Any] | None: + """优先按下标取,id 不符时按 id 查;都找不到返回 None(原实现会跳过该曲目)。""" + candidate = privileges[index] if index < len(privileges) else None + if isinstance(candidate, dict) and candidate.get("id") == item.get("id"): + return candidate + for privilege in privileges: + if isinstance(privilege, dict) and privilege.get("id") == item.get("id"): + return privilege + return None + + +def get_singer(singers: Any) -> str: + return "、".join(str(singer.get("name")) for singer in singers or [] if isinstance(singer, dict)) + + +def filter_list(songs: list[dict[str, Any]], privileges: list[dict[str, Any]]) -> list[dict[str, Any]]: + """song/detail 响应 → SongInfo 列表。""" + rows: list[dict[str, Any]] = [] + for index, item in enumerate(songs): + if not isinstance(item, dict): + continue + privilege = _match_privilege(privileges, item, index) + if privilege is None: + continue + types, _types = build_types(item, privilege) + album = item.get("al") or {} + pc = item.get("pc") + if pc: + # 云盘/翻唱曲目走 pc 字段(歌手/歌名/专辑为纯文本) + rows.append( + { + "singer": pc.get("ar") or "", + "name": pc.get("sn") or "", + "albumName": pc.get("alb") or "", + "albumId": album.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or 0) / 1000), + "songmid": item.get("id"), + "img": album.get("picUrl") or "", + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + else: + rows.append( + { + "singer": get_singer(item.get("ar")), + "name": item.get("name") or "", + "albumName": album.get("name"), + "albumId": album.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or 0) / 1000), + "songmid": item.get("id"), + "img": album.get("picUrl"), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return rows + + +async def get_list(ids: list[Any] | None = None, retry_num: int = 0) -> dict[str, Any]: + """按 id 批量取歌曲详情(weapi/v3/song/detail)。""" + ids = ids or [] + if retry_num > 2: + raise RuntimeError("try max num") + + form = weapi( + { + "c": "[" + ",".join('{"id":' + str(id_) + "}" for id_ in ids) + "]", + "ids": "[" + ",".join(str(id_) for id_ in ids) + "]", + } + ) + resp = await http_fetch( + "https://music.163.com/weapi/v3/song/detail", + method="post", + headers={**weapi_headers(with_cookie=False), "User-Agent": UA_LINUX_CHROME}, + form=form, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError("获取歌曲详情失败") + return {"source": "wy", "list": filter_list(body.get("songs") or [], body.get("privileges") or [])} diff --git a/app/sdk/platforms/wy/music_info.py b/app/sdk/platforms/wy/music_info.py new file mode 100644 index 0000000..ecca4a4 --- /dev/null +++ b/app/sdk/platforms/wy/music_info.py @@ -0,0 +1,34 @@ +"""网易云歌曲信息(移植自 sdk/wy/musicInfo.ts,走 weapi/v3/song/detail 取原始 song 对象)。 + +与 music_detail.py 的差别:这里返回上游原始 `songs[0]`(含 al/ar/hr/sq 等),供封面等场景直接取值。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + + +async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]: + """取原始 song 对象。失败文案与原文一致:获取歌曲信息失败。""" + song_mid = song_info.get("songmid") + resp = await http_fetch( + "https://music.163.com/weapi/v3/song/detail", + method="post", + headers={ + **weapi_headers(f"https://music.163.com/song?id={song_mid}", with_cookie=False), + "User-Agent": UA_LINUX_CHROME, + }, + form=weapi({"c": f'[{{"id":{song_mid}}}]', "ids": f"[{song_mid}]"}), + ) + body = resp.body if isinstance(resp.body, dict) else {} + songs = body.get("songs") or [] + if body.get("code") != 200 or not songs: + raise RuntimeError("获取歌曲信息失败") + song = songs[0] + if not isinstance(song, dict): + raise RuntimeError("获取歌曲信息失败") + return song diff --git a/app/sdk/platforms/wy/music_search.py b/app/sdk/platforms/wy/music_search.py new file mode 100644 index 0000000..cf11a69 --- /dev/null +++ b/app/sdk/platforms/wy/music_search.py @@ -0,0 +1,125 @@ +"""网易云搜索(移植自 sdk/wy/musicSearch.ts)。 + +走 eapi 通道 /api/search/song/list/page(cloudsearch pc 已被注释掉,保留原样)。 +响应结构是 resources[].baseInfo.simpleSongData,与 song/detail 的扁平结构不同, +因此音质解析单独一份(privilege 内嵌在 simpleSongData 里)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.utils import format_play_time, size_formate +from app.sdk.platforms.wy.utils import eapi_request + +DEFAULT_LIMIT = 30 + +# 模块级状态与原 TS 一致(路由不读这些字段,但 allPage/total 由它们推导) +_state: dict[str, Any] = {"total": 0, "page": 0, "all_page": 1, "limit": DEFAULT_LIMIT} + + +def get_singer(singers: Any) -> str: + return "、".join(str(singer.get("name")) for singer in singers or [] if isinstance(singer, dict)) + + +def _size_of(block: Any) -> str | None: + if isinstance(block, dict) and block.get("size"): + return size_formate(block["size"]) + return None + + +def handle_result(raw_list: Any) -> list[dict[str, Any]]: + if not raw_list: + return [] + rows: list[dict[str, Any]] = [] + for raw in raw_list: + item = ((raw or {}).get("baseInfo") or {}).get("simpleSongData") or {} + privilege = item.get("privilege") or {} + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + + if privilege.get("maxBrLevel") == "hires": + size = _size_of(item.get("hr")) + types.append({"type": "flac24bit", "size": size}) + _types["flac24bit"] = {"size": size} + + maxbr = privilege.get("maxbr") + # 无 break 的 switch 直落:高码率同时声明向下兼容档位 + if maxbr in (999000, 320000, 192000, 128000): + if maxbr == 999000: + size = _size_of(item.get("sq")) + types.append({"type": "flac", "size": size}) + _types["flac"] = {"size": size} + if maxbr in (999000, 320000): + size = _size_of(item.get("h")) + types.append({"type": "320k", "size": size}) + _types["320k"] = {"size": size} + size = _size_of(item.get("l")) + types.append({"type": "128k", "size": size}) + _types["128k"] = {"size": size} + + types.reverse() + + album = item.get("al") or {} + rows.append( + { + "singer": get_singer(item.get("ar")), + "name": item.get("name"), + "albumName": album.get("name"), + "albumId": album.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or 0) / 1000), + "songmid": item.get("id"), + "img": album.get("picUrl"), + "lrc": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return rows + + +async def _music_search(keyword: str, page: int, limit: int) -> Any: + resp = await eapi_request( + "/api/search/song/list/page", + { + "keyword": keyword, + "needCorrect": "1", + "channel": "typing", + "offset": limit * (page - 1), + "scene": "normal", + "total": page == 1, + "limit": limit, + }, + ) + return resp.body + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + """搜索单曲。失败重试上限 3 次(文案与原文一致:try max num)。""" + retry_num += 1 + if retry_num > 3: + raise RuntimeError("try max num") + if not limit: + limit = DEFAULT_LIMIT + + result = await _music_search(keyword, page, limit) + if not isinstance(result, dict) or result.get("code") != 200: + return await search(keyword, page, limit, retry_num) + + data = result.get("data") or {} + rows = handle_result(data.get("resources") or []) + + _state["total"] = data.get("totalCount") or 0 + _state["page"] = page + _state["all_page"] = -(-int(_state["total"]) // DEFAULT_LIMIT) # ceil 除法 + _state["limit"] = DEFAULT_LIMIT + + return { + "list": rows, + "allPage": _state["all_page"], + "limit": DEFAULT_LIMIT, + "total": _state["total"], + "source": "wy", + } diff --git a/app/sdk/platforms/wy/parse.py b/app/sdk/platforms/wy/parse.py new file mode 100644 index 0000000..fc187a5 --- /dev/null +++ b/app/sdk/platforms/wy/parse.py @@ -0,0 +1,80 @@ +"""网易云链接解析(移植自 sdk/wy/parse.ts,上游 music-lib/netease/song.go 的 Parse)。 + +歌曲链接形态: + https://music.163.com/#/song?id=29732995 + https://music.163.com/song?id=29732995 + https://music.163.com/song/29732995 +裸数字 id 也接受(补充便利;上游仅接受链接)。 +直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致); +歌单/专辑链接抛清晰错误,提示改用 parse_playlist / parse_album。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.wy.album import convert_song, parse_album_link +from app.sdk.platforms.wy.link import parse_netease_link +from app.sdk.platforms.wy.playlist import parse_playlist_link +from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + + +async def fetch_song_by_id(song_id: str) -> dict[str, Any]: + """上游 fetchSongsBatch 的单曲版(weapi/v3/song/detail)。""" + resp = await http_fetch( + "https://music.163.com/weapi/v3/song/detail", + method="post", + headers={**weapi_headers(), "User-Agent": UA_LINUX_CHROME}, + form=weapi({"c": f'[{{"id":{song_id}}}]', "ids": f"[{song_id}]"}), + timeout=15000, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError(f"wy 歌曲详情获取失败 (code {body.get('code', resp.status_code)})") + songs = body.get("songs") or [] + item = songs[0] if songs else None + if not isinstance(item, dict) or not item.get("id"): + raise RuntimeError("netease song not found") + return convert_song(item) + + +async def parse_song(link: str) -> dict[str, Any]: + """歌曲链接 → SongInfo(尽力附带直链 url/type)。""" + raw = str(link or "").strip() + if not raw: + raise RuntimeError("无效的网易云歌曲链接") + + if raw.isdigit(): + song_id = raw + else: + try: + kind, parsed_id = parse_netease_link(raw) + except ValueError as err: + raise RuntimeError("无效的网易云歌曲链接") from err + if kind == "playlist": + raise RuntimeError("检测到网易云歌单链接,请使用 parsePlaylist") + if kind == "album": + raise RuntimeError("检测到网易云专辑链接,请使用 parseAlbum") + song_id = parsed_id + + song = await fetch_song_by_id(song_id) + try: + # 延迟导入:apis.wy 反向依赖 platforms.wy.utils,模块级互相 import 会成环 + from app.sdk.apis import wy as wy_api + + hit = await wy_api.get_music_url(song, "320k") + song["url"] = hit["url"] + song["type"] = hit["type"] + except Exception as err: # noqa: BLE001 - 与上游一致:直链失败忽略(VIP/下架曲目仍返回元数据) + print(f"[wy] 解析歌曲时取流失败(已忽略): {err}") + return song + + +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) diff --git a/app/sdk/platforms/wy/pic.py b/app/sdk/platforms/wy/pic.py new file mode 100644 index 0000000..dc25a22 --- /dev/null +++ b/app/sdk/platforms/wy/pic.py @@ -0,0 +1,19 @@ +"""网易云封面(移植自 sdk/wy/index.ts 的 getPic → musicInfo.ts 的 song_detail)。 + +原文行为:取 song/detail 的 `al.picUrl`,失败即报错(无降级链)。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.platforms.wy.music_info import get_music_info + + +async def get_pic(song_info: dict[str, Any]) -> str: + info = await get_music_info(song_info) + album = info.get("al") or {} + url = album.get("picUrl") + if not url: + raise RuntimeError("获取封面失败") + return str(url) diff --git a/app/sdk/platforms/wy/playlist.py b/app/sdk/platforms/wy/playlist.py new file mode 100644 index 0000000..30a58c4 --- /dev/null +++ b/app/sdk/platforms/wy/playlist.py @@ -0,0 +1,198 @@ +"""网易云歌单能力(移植自 sdk/wy/playlist.ts,上游 music-lib/netease/playlist.go)。 + +搜索/分类:复用 song_list(search=cloudsearch type1000、get_tag=catalogue、get_list=playlist/list) +推荐:weapi/personalized/playlist(匿名可用) +详情/曲目:复用 song_list.get_list_detail(linuxapi forward),按 page/limit 切片 +链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_count +from app.sdk.platforms.wy import song_list +from app.sdk.platforms.wy.link import parse_netease_link +from app.sdk.platforms.wy.utils import weapi_headers +from app.sdk.platforms.wy.utils.crypto import weapi + +PLAYLIST_LINK = "https://music.163.com/#/playlist?id={}" + + +def playlist_link(playlist_id: str) -> str: + return PLAYLIST_LINK.format(playlist_id) + + +def api_limit() -> int: + """catalogue/list 接口固定页大小(song_list.LIMIT_LIST 运行时读取,避免模块环上的顺序问题)。""" + try: + return int(song_list.LIMIT_LIST) or 30 + except (TypeError, ValueError): + return 30 + + +def to_summary(item: Any, category_id: str | None = None) -> dict[str, Any] | None: + """歌单摘要:兼容 song_list 既有字段并补 spec 形状(id/name/cover/trackCount/creator/...)。""" + if not isinstance(item, dict): + return None + id_ = str(item.get("id") or "").strip() + if not id_: + return None + track_count = int(item.get("trackCount") or item.get("total") or 0) + play_count = int(item.get("playCount") or 0) + summary: dict[str, Any] = { + **item, + "id": id_, + "name": item.get("name") or "", + "cover": item.get("cover") or item.get("img") or "", + "trackCount": track_count, + "playCount": play_count, + "play_count": item.get("play_count") or format_play_count(play_count), + "creator": item.get("creator") or item.get("author") or "", + "description": item.get("description") or item.get("desc") or "", + "link": playlist_link(id_), + "source": "wy", + } + if category_id: + summary["categoryId"] = category_id + return summary + + +async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌单搜索(复用 song_list.search)。""" + result = await song_list.search(str(keyword or ""), page, limit) + rows = [summary for item in result.get("list") or [] if (summary := to_summary(item))] + return {"list": rows, "page": page, "limit": limit, "total": result.get("total") or len(rows), "source": "wy"} + + +async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]: + """歌单详情(复用 song_list.get_list_detail,按 page/limit 切片)。""" + detail = await song_list.get_list_detail(str(playlist_id or ""), 1) + start = max(0, (page - 1) * limit) + songs = detail.get("list") or [] + return { + "source": "wy", + "info": detail.get("info"), + "list": songs[start : start + limit], + "page": page, + "limit": limit, + "total": detail.get("total") or len(songs), + } + + +async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]: + """歌单曲目全量(路由在详情签名不匹配时也会用这条兜底)。""" + return list((await song_list.get_list_detail(str(playlist_id or ""), 1)).get("list") or []) + + +async def _safe(coro: Any) -> Any: + """吞掉分类接口异常并退回空列表(原实现是 .catch(() => []))。""" + try: + return await coro + except Exception as err: # noqa: BLE001 - 分类是可选增强,不应让整页 502 + print(f"[wy] 歌单分类接口失败(已降级为空): {err}") + return [] + + +async def get_categories() -> dict[str, Any]: + """歌单分类:摊平 catalogue + hottags,首项固定「全部」。单个接口失败不拖垮整体。""" + groups, hot_tags = await asyncio.gather(_safe(song_list.get_tag()), _safe(song_list.get_hot_tag())) + hot_set = {str(item.get("id")) for item in hot_tags or [] if isinstance(item, dict)} + rows: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}] + seen = {""} + for group in groups or []: + if not isinstance(group, dict): + continue + for item in group.get("list") or []: + if not isinstance(item, dict): + continue + id_ = str(item.get("id") or "").strip() + name = str(item.get("name") or "").strip() + if not id_ or not name or id_ in seen: + continue + seen.add(id_) + rows.append({"id": id_, "name": name, "group": group.get("name") or "", "hot": id_ in hot_set}) + return {"source": "wy", "list": rows} + + +async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """分类歌单:接口每页 30 条,按入参 limit 重新切片。""" + cat = str(category_id or "").strip() or "全部" + start = max(0, (page - 1) * limit) + page_size = api_limit() + api_pages = max(1, -(-(start + limit) // page_size)) # ceil 除法 + + items: list[dict[str, Any]] = [] + total = 0 + for api_page in range(1, api_pages + 1): + result = await song_list.get_list("hot", cat, api_page) + total = int(result.get("total") or total) or total + batch = result.get("list") or [] + items.extend(batch) + if len(batch) < page_size: + break + + rows = [summary for item in items[start : start + limit] if (summary := to_summary(item, cat))] + return {"list": rows, "page": page, "limit": limit, "total": total or len(items), "source": "wy"} + + +async def get_recommended(page: int = 1, limit: int = 20) -> dict[str, Any]: + """推荐歌单(weapi/personalized/playlist)。""" + resp = await http_fetch( + "https://music.163.com/weapi/personalized/playlist", + method="post", + headers=weapi_headers(), + form=weapi({"limit": 30, "total": True, "n": 1000}), + timeout=15000, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != 200: + raise RuntimeError(f"wy 推荐歌单获取失败 (code {body.get('code', resp.status_code)})") + + rows: list[dict[str, Any]] = [] + for item in body.get("result") or []: + if not isinstance(item, dict): + continue + id_ = str(item.get("id") or "").strip() + if not id_: + continue + play_count = int(item.get("playCount") or 0) + copywriter = item.get("copywriter") or "" + rows.append( + { + "id": id_, + "name": item.get("name") or "", + "cover": item.get("picUrl") or "", + "trackCount": int(item.get("trackCount") or 0), + "playCount": play_count, + "play_count": format_play_count(play_count), + "creator": copywriter or "网易云推荐", + "description": copywriter, + "link": playlist_link(id_), + "alg": item.get("alg") or "", + "source": "wy", + } + ) + if not rows: + raise RuntimeError("no recommended playlists found") + + start = max(0, (page - 1) * limit) + return {"list": rows[start : start + limit], "page": page, "limit": limit, "total": len(rows), "source": "wy"} + + +get_recommended_playlists = get_recommended + + +async def parse_playlist_link(link: str) -> dict[str, Any]: + """歌单链接解析(裸数字 id 兜底)。""" + raw = str(link or "").strip() + if not raw: + raise RuntimeError("无效的网易云歌单链接") + if raw.isdigit(): + return await get_playlist_detail(raw) + kind, id_ = parse_netease_link(raw) + if kind != "playlist": + raise RuntimeError("无效的网易云歌单链接") + return await get_playlist_detail(id_) diff --git a/app/sdk/platforms/wy/singer.py b/app/sdk/platforms/wy/singer.py new file mode 100644 index 0000000..fc89aee --- /dev/null +++ b/app/sdk/platforms/wy/singer.py @@ -0,0 +1,148 @@ +"""网易云歌手(移植自 sdk/wy/singer.ts)。 + +三个 eapi 接口: + * /api/artist/head/info/get → 歌手信息 + * /api/v2/artist/songs → 歌曲列表(音质档位来自 privilege.chargeInfoList) + * /api/artist/albums/<id> → 专辑列表 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.common.utils import format_play_time, format_singer_name, size_formate +from app.sdk.platforms.wy.utils import eapi_request + + +async def get_info(singer_id: str) -> dict[str, Any]: + """歌手信息。失败文案与原文一致:get singer info faild.。""" + resp = await eapi_request("/api/artist/head/info/get", {"id": singer_id}) + body = resp.body if isinstance(resp.body, dict) else {} + if not body or body.get("code") != 200: + raise RuntimeError("get singer info faild.") + artist = (body.get("data") or {}).get("artist") + if not isinstance(artist, dict): + raise RuntimeError("get singer info faild.") + return { + "source": "wy", + "id": artist.get("id"), + "info": { + "name": artist.get("name"), + "desc": artist.get("briefDesc"), + "avatar": artist.get("avatar") or artist.get("cover") or None, + "gender": None, + }, + "count": { + "music": artist.get("musicSize"), + "album": artist.get("albumSize"), + }, + } + + +def _size_of(block: Any) -> str | None: + if isinstance(block, dict) and block.get("size"): + return size_formate(block["size"]) + return None + + +def filter_song_list(raw: Any) -> list[dict[str, Any]]: + """歌手歌曲 → SongInfo。档位来自 privilege.chargeInfoList 的 rate(无直落,逐条 break)。""" + rows: list[dict[str, Any]] = [] + for item in raw or []: + if not isinstance(item, dict) or not item.get("id"): + continue + types: list[dict[str, Any]] = [] + _types: dict[str, dict[str, Any]] = {} + privilege = item.get("privilege") or {} + for info in privilege.get("chargeInfoList") or []: + if not isinstance(info, dict): + continue + rate = info.get("rate") + if rate == 128000: + size = _size_of(item.get("lMusic")) + types.append({"type": "128k", "size": size}) + _types["128k"] = {"size": size} + elif rate == 320000: + size = _size_of(item.get("hMusic")) + types.append({"type": "320k", "size": size}) + _types["320k"] = {"size": size} + elif rate == 999000: + size = _size_of(item.get("sqMusic")) + types.append({"type": "flac", "size": size}) + _types["flac"] = {"size": size} + elif rate == 1999000: + size = _size_of(item.get("hrMusic")) + types.append({"type": "flac24bit", "size": size}) + _types["flac24bit"] = {"size": size} + + raw_album = item.get("album") + album: dict[str, Any] = raw_album if isinstance(raw_album, dict) else {} + rows.append( + { + "singer": format_singer_name(item.get("artists")), + "name": item.get("name"), + "albumName": album.get("name"), + "albumId": album.get("id"), + "songmid": item.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or item.get("duration") or 0) / 1000), + "img": album.get("picUrl"), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return rows + + +async def get_song_list(singer_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌手歌曲列表。失败文案:get singer song list faild.。""" + resp = await eapi_request("/api/v2/artist/songs", {"id": singer_id, "limit": limit, "offset": (page - 1) * limit}) + body = resp.body if isinstance(resp.body, dict) else {} + if not body.get("songs") or body.get("code") != 200: + raise RuntimeError("get singer song list faild.") + return { + "list": filter_song_list(body.get("songs")), + "limit": limit, + "page": page, + "total": body.get("total"), + "source": "wy", + } + + +def filter_album_list(raw: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in raw or []: + if not isinstance(item, dict) or not item.get("id"): + continue + rows.append( + { + "id": item.get("id"), + "count": item.get("size"), + "info": { + "name": item.get("name"), + "author": format_singer_name(item.get("artists")), + "img": item.get("picUrl"), + "desc": None, + }, + } + ) + return rows + + +async def get_album_list(singer_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌手专辑列表。失败文案:get singer album list faild.。""" + resp = await eapi_request(f"/api/artist/albums/{singer_id}", {"limit": limit, "offset": (page - 1) * limit}) + body = resp.body if isinstance(resp.body, dict) else {} + if not body.get("hotAlbums") or body.get("code") != 200: + raise RuntimeError("get singer album list faild.") + artist = body.get("artist") or {} + return { + "source": "wy", + "list": filter_album_list(body.get("hotAlbums")), + "limit": limit, + "page": page, + "total": artist.get("albumSize"), + } diff --git a/app/sdk/platforms/wy/song_list.py b/app/sdk/platforms/wy/song_list.py new file mode 100644 index 0000000..dca6ce9 --- /dev/null +++ b/app/sdk/platforms/wy/song_list.py @@ -0,0 +1,335 @@ +"""网易云歌单(移植 sdk/wy/songList.ts)。 + +四条数据源: + * playlist/list(weapi) → get_list:分类歌单列表 + * playlist/catalogue + hottags → get_tag / get_hot_tag:分类与热门标签 + * linux/forward + v3/playlist/detail → get_list_detail:歌单详情与曲目 + * cloudsearch pc(eapi,type=1000) → search:歌单搜索 +`getListId` 负责把「歌单链接 / 裸 id / id###token」归一成纯 id。 +""" + +from __future__ import annotations + +import asyncio +import re +from typing import Any + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import date_format, format_play_count, format_play_time, format_singer_name, size_formate +from app.sdk.platforms.wy import music_detail +from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, eapi_request, weapi_headers +from app.sdk.platforms.wy.utils.crypto import linuxapi, weapi + +LIMIT_LIST = 30 +LIMIT_SONG = 100000 +SUCCESS_CODE = 200 +DEFAULT_COOKIE = "MUSIC_U=" + +SORT_LIST = [{"name": "最热", "id": "hot"}] +# 路由读 module.song_list.sort_list 取默认排序,必须与路由约定同名(小写) +sort_list = SORT_LIST + +RX_LIST_DETAIL_LINK = re.compile(r"^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)") +RX_LIST_DETAIL_LINK2 = re.compile(r"^.+/playlist/(\d+)/\d+/.+$") + +# 歌单详情用的 MUSIC_U(原实现把它存在模块状态上:id 里带 ###token 时会被记住) +_state: dict[str, str] = {"cookie": DEFAULT_COOKIE} + + +async def handle_parse_id(link: str, retry_num: int = 0) -> str: + """跟随重定向(最多 2 次)后从最终 URL 里取出歌单 id。""" + if retry_num > 2: + raise RuntimeError("link try max num") + + resp = await http_fetch(link) + if resp.status_code > 400: + return await handle_parse_id(link, retry_num + 1) + location = resp.header("location") + url = location if location else link + if RX_LIST_DETAIL_LINK.match(url): + return RX_LIST_DETAIL_LINK.sub(r"\1", url) + return RX_LIST_DETAIL_LINK2.sub(r"\1", url) + + +async def get_list_id(raw_id: str) -> dict[str, str | None]: + """把歌单链接/裸 id/`id###token` 归一成 {id, cookie}。""" + cookie: str | None = None + id_ = raw_id + if "###" in id_: + url, _, token = id_.partition("###") + id_ = url + cookie = f"MUSIC_U={token}" + if re.search(r"[?&:/]", id_): + if RX_LIST_DETAIL_LINK.match(id_): + id_ = RX_LIST_DETAIL_LINK.sub(r"\1", id_) + elif RX_LIST_DETAIL_LINK2.match(id_): + id_ = RX_LIST_DETAIL_LINK2.sub(r"\1", id_) + else: + id_ = await handle_parse_id(id_) + return {"id": id_, "cookie": cookie} + + +def _size_of(block: Any) -> str | None: + if isinstance(block, dict) and block.get("size"): + return size_formate(block["size"]) + return None + + +def filter_list_detail(playlist: dict[str, Any], privileges: list[dict[str, Any]]) -> list[dict[str, Any]]: + """歌单详情曲目 → SongInfo。原实现此处 flac 的 size 恒为 None,保持一样。""" + tracks = playlist.get("tracks") or [] + rows: list[dict[str, Any]] = [] + for index, item in enumerate(tracks): + if not isinstance(item, dict): + continue + privilege = privileges[index] if index < len(privileges) else None + if not isinstance(privilege, dict) or privilege.get("id") != item.get("id"): + privilege = next((p for p in privileges if isinstance(p, dict) and p.get("id") == item.get("id")), None) + if privilege is None: + continue + + types, _types = music_detail.build_types(item, privilege, flac_size=False) + album = item.get("al") or {} + pc = item.get("pc") + if pc: + rows.append( + { + "singer": pc.get("ar") or "", + "name": pc.get("sn") or "", + "albumName": pc.get("alb") or "", + "albumId": album.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or 0) / 1000), + "songmid": item.get("id"), + "img": album.get("picUrl") or "", + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + else: + rows.append( + { + "singer": format_singer_name(item.get("ar"), "name"), + "name": item.get("name") or "", + "albumName": album.get("name"), + "albumId": album.get("id"), + "source": "wy", + "interval": format_play_time((item.get("dt") or 0) / 1000), + "songmid": item.get("id"), + "img": album.get("picUrl"), + "lrc": None, + "otherSource": None, + "types": types, + "_types": _types, + "typeUrl": {}, + } + ) + return rows + + +async def get_list_detail(raw_id: str, page: int = 1, try_num: int = 0) -> dict[str, Any]: + """歌单详情(linux/forward 转发 v3/playlist/detail)。失败重试 3 次。""" + if try_num > 2: + raise RuntimeError("try max num") + + resolved = await get_list_id(str(raw_id or "")) + id_ = str(resolved["id"] or "") + if resolved["cookie"]: + _state["cookie"] = str(resolved["cookie"]) + + headers = {"User-Agent": UA_LINUX_CHROME, "Cookie": _state["cookie"]} + form = linuxapi( + { + "method": "POST", + "url": "https://music.163.com/api/v3/playlist/detail", + "params": {"id": id_, "n": LIMIT_SONG, "s": 8}, + } + ) + resp = await http_fetch("https://music.163.com/api/linux/forward", method="post", headers=headers, form=form) + body = resp.body if isinstance(resp.body, dict) else {} + if resp.status_code != 200 or body.get("code") != SUCCESS_CODE: + return await get_list_detail(id_, page, try_num + 1) + + limit = 1000 + playlist = body.get("playlist") or {} + track_ids = [track.get("id") for track in playlist.get("trackIds") or [] if isinstance(track, dict)] + privileges = body.get("privileges") or [] + + if len(track_ids) == len(privileges): + rows = filter_list_detail(playlist, privileges) + else: + range_start = (page - 1) * limit + slice_ids = track_ids[range_start : limit * page] + try: + rows = (await music_detail.get_list(slice_ids))["list"] + except Exception as err: + print(f"[wy] 歌单歌曲详情获取失败将重试({try_num + 1}): {err}") + if str(err) == "try max num": + raise + return await get_list_detail(id_, page, try_num + 1) + + creator = playlist.get("creator") or {} + return { + "list": rows, + "page": page, + "limit": limit, + "total": len(track_ids), + "source": "wy", + "info": { + "play_count": format_play_count(playlist.get("playCount")), + "name": playlist.get("name"), + "img": playlist.get("coverImgUrl"), + "desc": playlist.get("description"), + "author": creator.get("nickname"), + }, + } + + +def filter_list(raw_data: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in raw_data or []: + if not isinstance(item, dict): + continue + creator = item.get("creator") or {} + rows.append( + { + "play_count": format_play_count(item.get("playCount")), + "id": str(item.get("id")), + "author": creator.get("nickname"), + "name": item.get("name"), + "time": date_format(item.get("createTime"), "Y-M-D") if item.get("createTime") else "", + "img": item.get("coverImgUrl"), + "grade": item.get("grade"), + "total": item.get("trackCount"), + "desc": item.get("description"), + "source": "wy", + } + ) + return rows + + +async def get_list(sort_id: Any, tag_id: Any, page: int = 1, try_num: int = 0) -> dict[str, Any]: + """分类歌单列表(weapi/playlist/list)。失败重试 3 次。""" + if try_num > 2: + raise RuntimeError("try max num") + + form = weapi( + { + "cat": tag_id or "全部", + "order": sort_id, + "limit": LIMIT_LIST, + "offset": LIMIT_LIST * (page - 1), + "total": True, + } + ) + resp = await http_fetch( + "https://music.163.com/weapi/playlist/list", method="post", headers=weapi_headers(with_cookie=False), form=form + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_list(sort_id, tag_id, page, try_num + 1) + + try: + total = int(body.get("total") or 0) + except (TypeError, ValueError): + total = 0 + return { + "list": filter_list(body.get("playlists")), + "total": total, + "page": page, + "limit": LIMIT_LIST, + "source": "wy", + } + + +def filter_tag_info(body: dict[str, Any]) -> list[dict[str, Any]]: + categories = body.get("categories") or {} + grouped: dict[Any, list[dict[str, Any]]] = {} + for item in body.get("sub") or []: + if not isinstance(item, dict): + continue + category = item.get("category") + grouped.setdefault(category, []).append( + { + "parent_id": categories.get(category), + "parent_name": categories.get(category), + "id": item.get("name"), + "name": item.get("name"), + "source": "wy", + } + ) + return [{"name": categories[key], "list": grouped.get(key), "source": "wy"} for key in categories] + + +async def get_tag(try_num: int = 0) -> list[dict[str, Any]]: + """歌单分类树(weapi/playlist/catalogue)。失败重试 3 次。""" + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch( + "https://music.163.com/weapi/playlist/catalogue", + method="post", + headers=weapi_headers(with_cookie=False), + form=weapi({}), + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + return await get_tag(try_num + 1) + return filter_tag_info(body) + + +def filter_hot_tag_info(raw_list: Any) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for item in raw_list or []: + tag = (item or {}).get("playlistTag") or {} + if not tag.get("name"): + continue + rows.append({"id": tag.get("name"), "name": tag.get("name"), "source": "wy"}) + return rows + + +async def get_hot_tag(try_num: int = 0) -> list[dict[str, Any]]: + """热门标签(weapi/playlist/hottags)。失败重试 3 次。""" + if try_num > 2: + raise RuntimeError("try max num") + resp = await http_fetch( + "https://music.163.com/weapi/playlist/hottags", + method="post", + headers=weapi_headers(with_cookie=False), + form=weapi({}), + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + # 原实现此处误调 get_tag(返回形状不同),这里同样退回分类接口以保持行为可预期 + return await get_tag(try_num + 1) + return filter_hot_tag_info(body.get("tags")) + + +async def get_tags() -> dict[str, Any]: + tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag()) + return {"tags": tags, "hotTag": hot_tag, "source": "wy"} + + +async def get_detail_page_url(raw_id: str) -> str: + resolved = await get_list_id(str(raw_id or "")) + return f"https://music.163.com/#/playlist?id={resolved['id']}" + + +async def search(text: str, page: int = 1, limit: int = 20) -> dict[str, Any]: + """歌单搜索(eapi cloudsearch pc type=1000)。失败文案与原文一致:filed。""" + resp = await eapi_request( + "/api/cloudsearch/pc", + {"s": text, "type": 1000, "limit": limit, "total": page == 1, "offset": limit * (page - 1)}, + ) + body = resp.body if isinstance(resp.body, dict) else {} + if body.get("code") != SUCCESS_CODE: + raise RuntimeError("filed") + result = body.get("result") or {} + return { + "list": filter_list(result.get("playlists")), + "limit": limit, + "total": result.get("playlistCount"), + "source": "wy", + } diff --git a/app/sdk/platforms/wy/user_playlist.py b/app/sdk/platforms/wy/user_playlist.py new file mode 100644 index 0000000..266af4e --- /dev/null +++ b/app/sdk/platforms/wy/user_playlist.py @@ -0,0 +1,117 @@ +"""网易云个人歌单(移植自 sdk/wy/userPlaylist.ts,上游 music-lib/netease/user_playlist.go)。 + +需登录 cookie MUSIC_U(app/sdk/auth_store.py 的 get_credentials('wy')): +未登录直接抛清晰错误,不返回空列表 —— 空列表会被前端当成「用户没有歌单」。 + 1) weapi/nuser/account/get → 当前用户 userId + 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset) +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.auth_store import get_credentials, get_wy_cookie +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import format_play_count +from app.sdk.platforms.wy.utils import UA_LINUX_CHROME +from app.sdk.platforms.wy.utils.crypto import weapi + +PLAYLIST_LINK = "https://music.163.com/#/playlist?id={}" + + +def playlist_link(playlist_id: str) -> str: + return PLAYLIST_LINK.format(playlist_id) + + +def read_cookie() -> str: + """优先用 musicU 拼 MUSIC_U,其次退回已存 cookie / 通用 cookie。""" + cred = get_credentials("wy") or {} + music_u = str(cred.get("musicU") or "").strip() + if music_u: + return f"MUSIC_U={music_u}" + return str(cred.get("cookie") or get_wy_cookie()).strip() + + +async def _weapi_post(url: str, data: dict[str, Any], cookie: str) -> dict[str, Any]: + resp = await http_fetch( + url, + method="post", + headers={ + "User-Agent": UA_LINUX_CHROME, + "Referer": "https://music.163.com/", + "origin": "https://music.163.com", + "Content-Type": "application/x-www-form-urlencoded", + "Cookie": cookie, + }, + form=weapi(data), + timeout=15000, + ) + body = resp.body + if resp.status_code != 200 or not isinstance(body, dict): + raise RuntimeError("wy 个人歌单接口请求失败") + return body + + +async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]: + """个人歌单列表。未登录 / 登录态失效抛 RuntimeError,不返回空列表。""" + cookie = read_cookie() + if not cookie: + raise RuntimeError("wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云") + + safe_page = max(1, int(page or 1)) + safe_limit = int(limit or 30) + if safe_limit <= 0: + safe_limit = 30 + if safe_limit > 100: + safe_limit = 100 + + account = await _weapi_post("https://music.163.com/weapi/nuser/account/get", {"csrf_token": ""}, cookie) + profile = account.get("profile") or {} + if account.get("code") != 200 or not profile.get("userId"): + raise RuntimeError(f"wy 登录态无效或已过期 (code {account.get('code', 'unknown')})") + uid = profile.get("userId") + nickname = profile.get("nickname") or "" + + body = await _weapi_post( + "https://music.163.com/weapi/user/playlist", + { + "uid": uid, + "limit": safe_limit, + "offset": (safe_page - 1) * safe_limit, + "includeVideo": True, + "csrf_token": "", + }, + cookie, + ) + if body.get("code") != 200: + raise RuntimeError(f"wy 个人歌单获取失败 (code {body.get('code')})") + + rows: list[dict[str, Any]] = [] + for item in body.get("playlist") or []: + if not isinstance(item, dict): + continue + id_ = str(item.get("id") or "").strip() + if not id_: + continue + play_count = int(item.get("playCount") or 0) + creator = str((item.get("creator") or {}).get("nickname") or "").strip() or nickname + rows.append( + { + "id": id_, + "name": item.get("name") or "", + "cover": item.get("coverImgUrl") or "", + "trackCount": int(item.get("trackCount") or 0), + "playCount": play_count, + "play_count": format_play_count(play_count), + "creator": creator, + "description": item.get("description") or "", + "subscribed": bool(item.get("subscribed")), + "link": playlist_link(id_), + "source": "wy", + } + ) + + return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "wy"} + + +get_user_playlists = get_list diff --git a/app/sdk/platforms/wy/utils/__init__.py b/app/sdk/platforms/wy/utils/__init__.py new file mode 100644 index 0000000..397b46b --- /dev/null +++ b/app/sdk/platforms/wy/utils/__init__.py @@ -0,0 +1,54 @@ +"""网易云 eapi 请求通道(移植自 sdk/wy/utils/index.ts)。 + +登录后携带 MUSIC_U(见 app/sdk/auth/wy.py),未登录不附加 —— 匿名接口同样要求 +eapi 签名,区别只在 cookie 决定的可访问范围/音质。 + +`weapi_headers(with_cookie=False)` 用于原实现里本就不带 cookie 的 weapi 请求 +(playlist/list、catalogue、hottags、comment、toplist),避免登录态意外改变返回内容。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.auth_store import get_wy_cookie +from app.sdk.common.request import HttpResponse, http_fetch +from app.sdk.platforms.wy.utils.crypto import eapi + +UA_LINUX_CHROME = ( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36" +) +UA_WIN_CHROME = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + + +def weapi_headers(referer: str = "https://music.163.com/", *, with_cookie: bool = True) -> dict[str, str]: + headers = { + "User-Agent": UA_LINUX_CHROME, + "Referer": referer, + "origin": "https://music.163.com", + } + if with_cookie: + cookie = get_wy_cookie() + if cookie: + headers["cookie"] = cookie + return headers + + +async def eapi_request( + url: str, + data: dict[str, Any], + *, + host: str = "https://interface.music.163.com/eapi/batch", + timeout_ms: int = 60_000, + headers: dict[str, str] | None = None, +) -> HttpResponse: + """POST 到 eapi 通道。`url` 是业务路径(如 /api/song/enhance/player/url/v1)。""" + merged = {"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"} + cookie = get_wy_cookie() + if cookie: + merged["cookie"] = cookie + if headers: + merged.update(headers) + return await http_fetch(host, method="post", headers=merged, form=eapi(url, data), timeout=timeout_ms) diff --git a/app/sdk/platforms/wy/utils/crypto.py b/app/sdk/platforms/wy/utils/crypto.py new file mode 100644 index 0000000..cf42e35 --- /dev/null +++ b/app/sdk/platforms/wy/utils/crypto.py @@ -0,0 +1,99 @@ +"""eapi / weapi / linuxapi 加密 —— 移植自 sdk/wy/utils/crypto.ts。 + +算法与上游 Binaryify/NeteaseCloudMusicApi 的 util/crypto.js 逐字节一致: + * weapi 两层 AES-128-CBC(内层固定 presetKey,外层随机 secretKey),再用 RSA_NO_PADDING 包 secretKey + * linuxapi / eapi 走 AES-128-ECB + * Node 的 createCipheriv 默认 PKCS#7 自动补位,这里手工补齐;ECB 忽略 iv + +改这里会让所有 eapi 请求的签名失效,必须与上游保持一致。 +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +from typing import Any + +from cryptography.hazmat.primitives import padding as _sym_padding +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +IV = b"0102030405060708" +PRESET_KEY = b"0CoJUm6Qyw8W8jud" +LINUXAPI_KEY = b"rFgB&h#%2?^eDg:Q" +EAPI_KEY = b"e82ckenh8dichen8" +BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +PUBLIC_KEY = ( + "-----BEGIN PUBLIC KEY-----\n" + "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2" + "KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBG" + "LbCiK45wIDAQAB\n" + "-----END PUBLIC KEY-----\n" +) + + +def _aes_encrypt(data: bytes, key: bytes, iv: bytes = b"") -> bytes: + """AES 加密;iv 为空走 ECB,否则 CBC。补位方式与 Node 默认一致(PKCS#7)。""" + padder = _sym_padding.PKCS7(128).padder() + padded = padder.update(data) + padder.finalize() + cipher = Cipher(algorithms.AES(key), modes.CBC(iv) if iv else modes.ECB()) + encryptor = cipher.encryptor() + return encryptor.update(padded) + encryptor.finalize() + + +def _aes_decrypt(data: bytes, key: bytes) -> bytes: + cipher = Cipher(algorithms.AES(key), modes.ECB()) + decryptor = cipher.decryptor() + plain = decryptor.update(data) + decryptor.finalize() + unpadder = _sym_padding.PKCS7(128).unpadder() + return unpadder.update(plain) + unpadder.finalize() + + +def _rsa_encrypt(data: bytes, key: str = PUBLIC_KEY) -> bytes: + """RSA_NO_PADDING:调用方需自行左补零到模长(Node 版同样是手工 Buffer.alloc 补齐)。 + + cryptography 不提供 NoPadding(其 RSA 加密强制 PKCS#1 填充),而网易 weapi 的 + secretKey 必须裸加密才与上游一致,因此这里直接做 c = m^e mod n。 + """ + public_key = serialization.load_pem_public_key(key.encode()) + if not isinstance(public_key, rsa.RSAPublicKey): + raise RuntimeError("网易云 RSA 公钥解析失败") + numbers = public_key.public_numbers() + block_size = public_key.key_size // 8 + if len(data) > block_size: + raise RuntimeError("网易云 RSA 明文超过模长") + block = b"\x00" * (block_size - len(data)) + data + return pow(int.from_bytes(block, "big"), numbers.e, numbers.n).to_bytes(block_size, "big") + + +def _js_json(data: Any) -> str: + """对齐 JS 的 JSON.stringify(紧凑、保留非 ASCII)。""" + return json.dumps(data, ensure_ascii=False, separators=(",", ":")) + + +def weapi(data: Any) -> dict[str, str]: + text = _js_json(data) + # Node 的 randomBytes 是密码学随机,用 os.urandom 对齐;取模 62 后映射到 base62 字符的 ASCII 码 + secret_key = bytes(ord(BASE62[byte % 62]) for byte in os.urandom(16)) + inner = base64.b64encode(_aes_encrypt(text.encode(), PRESET_KEY, IV)) + params = base64.b64encode(_aes_encrypt(inner, secret_key, IV)).decode() + return {"params": params, "encSecKey": _rsa_encrypt(secret_key[::-1]).hex()} + + +def linuxapi(data: Any) -> dict[str, str]: + text = _js_json(data) + return {"eparams": _aes_encrypt(text.encode(), LINUXAPI_KEY).hex().upper()} + + +def eapi(url: str, data: Any) -> dict[str, str]: + text = data if isinstance(data, str) else _js_json(data) + digest = hashlib.md5(f"nobody{url}use{text}md5forencrypt".encode()).hexdigest() + raw = f"{url}-36cd479b6b5-{text}-36cd479b6b5-{digest}" + return {"params": _aes_encrypt(raw.encode(), EAPI_KEY).hex().upper()} + + +def eapi_decrypt(cipher_buffer: bytes) -> str: + return _aes_decrypt(cipher_buffer, EAPI_KEY).decode("utf-8", errors="replace") diff --git a/app/sdk/platforms/xm/__init__.py b/app/sdk/platforms/xm/__init__.py new file mode 100644 index 0000000..f2d3da6 --- /dev/null +++ b/app/sdk/platforms/xm/__init__.py @@ -0,0 +1,53 @@ +"""喜马拉雅(有声书/播客)平台模块(对应 sdk/xm/index.ts)。 + +匿名可用面(2026-10-02 实测):搜索 / 专辑曲目 / 免费曲目取流(PC baseInfo 通道 + 解密直链)/ 封面。 +付费与 VIP 内容:绑定登录 Cookie(POST /login/xm,须含 1&_token)后 baseInfo 授权放行; +超清(XimiUhq)需会员。有声内容不参与聚合搜索与跨平台匹配(sdk 标记 noAggregate), +仅按 source=xm 直查。 +""" + +from __future__ import annotations + +from typing import Any + +from app.sdk.auth_store import get_credentials +from app.sdk.platforms._base import Platform +from app.sdk.platforms.xm import album, music_search +from app.sdk.platforms.xm.music_info import cover_url, get_track_info + + +async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]: + from app.sdk.apis.xm import get_music_url + + return await get_music_url(song_info, quality, retry_num) + + +async def get_pic(song_info: dict[str, Any]) -> str | None: + return cover_url(await get_track_info(str(song_info.get("songmid") or ""))) + + +class _XmAlbum: + get_album_detail = staticmethod(album.get_album_detail) + + +def get_music_detail_page_url(song_info: dict[str, Any]) -> str: + return f"https://www.ximalaya.com/sound/{song_info.get('songmid') or ''}" + + +def status() -> dict[str, Any]: + """登录状态(凭据存在性判断,无在线校验接口)。""" + cred = get_credentials("xm") or {} + if cred.get("cookie"): + return {"logged": True, "updatedAt": cred.get("updatedAt")} + return {"logged": False} + + +MODULE = Platform( + id="xm", + name="喜马拉雅", + music_search=music_search, + album=_XmAlbum, + get_music_url=_get_music_url, + get_pic=get_pic, + get_music_detail_page_url=get_music_detail_page_url, +) diff --git a/app/sdk/platforms/xm/album.py b/app/sdk/platforms/xm/album.py new file mode 100644 index 0000000..4b76b1b --- /dev/null +++ b/app/sdk/platforms/xm/album.py @@ -0,0 +1,71 @@ +"""喜马拉雅专辑曲目列表(对应 sdk/xm/album.ts,免签匿名接口)。 + +接口: https://www.ximalaya.com/revision/album/getTracksList?albumId=&pageNum=&sort=0 +实测(2026-10-02)匿名可翻全部页(固定每页 30 条);仅曲目元信息(trackId/title/isPaid), +播放地址需另走 baseInfo。注意 v1 版接口(/revision/album/v1/getTracksList)需要 webtk 签名且有风控。 +""" + +from __future__ import annotations + +import math +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.xm.sign import XM_UA + +TRACKS_URL = "https://www.ximalaya.com/revision/album/getTracksList" +PAGE_SIZE = 30 + + +async def get_tracks_page(album_id: str, page_num: int) -> dict[str, Any]: + """抓取专辑第 page_num 页曲目(返回 {total, tracks})。""" + resp = await http_fetch( + f"{TRACKS_URL}?albumId={album_id}&pageNum={page_num}&sort=0", + headers={"User-Agent": XM_UA, "Referer": f"https://www.ximalaya.com/album/{album_id}"}, + timeout=15_000, + ) + body = as_dict(resp.body) + if resp.status_code != 200 or body.get("ret") != 200 or not body.get("data"): + raise RuntimeError(str(body.get("msg") or f"获取专辑曲目失败(ret {body.get('ret', resp.status_code)})")) + data = as_dict(body.get("data")) + return { + "total": int(data.get("trackTotalCount") or 0), + "tracks": [item for item in as_list(data.get("tracks")) if isinstance(item, dict)], + } + + +async def get_album_detail(album_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]: + """专辑详情 + 指定页曲目(/album 路由适配:{info, list},接口固定每页 30 条)。""" + result = await get_tracks_page(album_id, page) + total = result["total"] + album_name = "" + songs: list[dict[str, Any]] = [] + for item in result["tracks"][:limit]: + if not album_name and item.get("albumTitle"): + album_name = str(item["albumTitle"]) + songs.append( + { + "songmid": str(item.get("trackId")), + "name": item.get("title") or str(item.get("trackId")), + "singer": item.get("albumTitle") or "", + "albumId": str(album_id), + "albumName": item.get("albumTitle") or "", + "interval": 0, + "payPlay": bool(item.get("isPaid")), + "img": None, + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + return { + "info": {"name": album_name or str(album_id), "total": total}, + "list": songs, + "total": total, + "limit": limit, + "allPage": math.ceil(total / PAGE_SIZE) or page, + "source": "xm", + } diff --git a/app/sdk/platforms/xm/decoder.py b/app/sdk/platforms/xm/decoder.py new file mode 100644 index 0000000..e8626b5 --- /dev/null +++ b/app/sdk/platforms/xm/decoder.py @@ -0,0 +1,354 @@ +"""喜马拉雅播放地址解码(对应 sdk/xm/decoder.ts,移植 Ximalaya-Downloader-Next adapters/decoder.py)。 + +算法:URL-safe Base64 → 尾 16 字节为 IV,正文先查 S-Box 置换表, +再按 16 字节块与 IV 异或、按 32 字节块与固定密钥异或,还原明文 URL。 +PC 桌面端(device=win)另有 AES-ECB 变体(密钥取自官方客户端 4.0.14 asar)。 +""" + +from __future__ import annotations + +import base64 + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from app.sdk.platforms.kg.util import pkcs7_unpad + +#: S-Box 置换表(www2/mweb2 设备类型) +PERMUTATION_TABLE_O = [ + 183, + 174, + 108, + 16, + 131, + 159, + 250, + 5, + 239, + 110, + 193, + 202, + 153, + 137, + 251, + 176, + 119, + 150, + 47, + 204, + 97, + 237, + 1, + 71, + 177, + 42, + 88, + 218, + 166, + 82, + 87, + 94, + 14, + 195, + 69, + 127, + 215, + 240, + 225, + 197, + 238, + 142, + 123, + 44, + 219, + 50, + 190, + 29, + 181, + 186, + 169, + 98, + 139, + 185, + 152, + 13, + 141, + 76, + 6, + 157, + 200, + 132, + 182, + 49, + 20, + 116, + 136, + 43, + 155, + 194, + 101, + 231, + 162, + 242, + 151, + 213, + 53, + 60, + 26, + 134, + 211, + 56, + 28, + 223, + 107, + 161, + 199, + 15, + 229, + 61, + 96, + 41, + 66, + 158, + 254, + 21, + 165, + 253, + 103, + 89, + 3, + 168, + 40, + 246, + 81, + 95, + 58, + 31, + 172, + 78, + 99, + 45, + 148, + 187, + 222, + 124, + 55, + 203, + 235, + 64, + 68, + 149, + 180, + 35, + 113, + 207, + 118, + 111, + 91, + 38, + 247, + 214, + 7, + 212, + 209, + 189, + 241, + 18, + 115, + 173, + 25, + 236, + 121, + 249, + 75, + 57, + 216, + 10, + 175, + 112, + 234, + 164, + 70, + 206, + 198, + 255, + 140, + 230, + 12, + 32, + 83, + 46, + 245, + 0, + 62, + 227, + 72, + 191, + 156, + 138, + 248, + 114, + 220, + 90, + 84, + 170, + 128, + 19, + 24, + 122, + 146, + 80, + 39, + 37, + 8, + 34, + 22, + 11, + 93, + 130, + 63, + 154, + 244, + 160, + 144, + 79, + 23, + 133, + 92, + 54, + 102, + 210, + 65, + 67, + 27, + 196, + 201, + 106, + 143, + 52, + 74, + 100, + 217, + 179, + 48, + 233, + 126, + 117, + 184, + 226, + 85, + 171, + 167, + 86, + 2, + 147, + 17, + 135, + 228, + 252, + 105, + 30, + 192, + 129, + 178, + 120, + 36, + 145, + 51, + 163, + 77, + 205, + 73, + 4, + 188, + 125, + 232, + 33, + 243, + 109, + 224, + 104, + 208, + 221, + 59, + 9, +] + +XOR_KEY_A = [ + 204, + 53, + 135, + 197, + 39, + 73, + 58, + 160, + 79, + 24, + 12, + 83, + 180, + 250, + 101, + 60, + 206, + 30, + 10, + 227, + 36, + 95, + 161, + 16, + 135, + 150, + 235, + 116, + 242, + 116, + 165, + 171, +] + +#: PC 桌面端 playUrlList 的 AES-ECB 密钥(客户端 4.0.14 asar Gt 函数提取) +WIN_AES_KEY = bytes.fromhex("aaad3e4fd540b0f79dca95606e72bf93") + + +def _b64url_to_buf(text: str) -> bytes: + cleaned = text.replace("_", "/").replace("-", "+") + cleaned += "=" * (-len(cleaned) % 4) + return base64.b64decode(cleaned) + + +def decode_www2_url(encrypted: str) -> str: + """www2/mweb2 加密 URL → 明文 URL。""" + if not encrypted: + return "" + if encrypted.startswith("http"): + return encrypted + decoded = _b64url_to_buf(encrypted) + if len(decoded) < 16: + raise RuntimeError("密文长度不足(缺少 IV)") + body = bytearray(decoded[:-16]) + iv = decoded[-16:] + + for i in range(len(body)): + body[i] = PERMUTATION_TABLE_O[body[i]] + for i in range(0, len(body), 16): + count = min(16, len(body) - i) + for j in range(count): + body[i + j] ^= iv[j] + for i in range(0, len(body), 32): + count = min(32, len(body) - i) + for j in range(count): + body[i + j] ^= XOR_KEY_A[j] + return bytes(body).decode("utf-8", errors="replace") + + +def decode_win_url(encrypted: str) -> str: + """PC 桌面端(device=win)AES-ECB 加密 URL → 明文 URL(解出的是带签名付费地址)。""" + if not encrypted: + return "" + if encrypted.startswith("http"): + return encrypted + decryptor = Cipher(algorithms.AES(WIN_AES_KEY), modes.ECB()).decryptor() + plain = decryptor.update(_b64url_to_buf(encrypted)) + decryptor.finalize() + return pkcs7_unpad(plain).decode("utf-8", errors="replace") diff --git a/app/sdk/platforms/xm/device_info.py b/app/sdk/platforms/xm/device_info.py new file mode 100644 index 0000000..059f3a7 --- /dev/null +++ b/app/sdk/platforms/xm/device_info.py @@ -0,0 +1,260 @@ +"""喜马拉雅 du_web_sdk 设备指纹模板(对应 sdk/xm/deviceInfo.ts)。 + +内置公共指纹,与 Ximalaya-Downloader-Next 的 device_info_default.json 同源; +Zf5 时间戳每次上报前刷新;UA 字段 yV2 与请求 UA 保持一致。 +""" + +from __future__ import annotations + +from typing import Any + +DEVICE_INFO: dict[str, Any] = { + "Zf5": 0, + "GF9": "2.0.0", + "HW5": "t6pfoml9679z52kqw93uqu75eflqdg1bykhl", + "uS7": "", + "KFp": "h5_goyxvzyohd", + "ew1": { + "Wg7": "Mozilla", + "lV1": "Google Inc.", + "Xt4": "Netscape", + "yV2": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 " + "Safari/537.36", + "KY1": "Win32", + "Le3": "Tmxhcm9vei81LjAgKERybXdsZGggTUcgMTAuMDsgRHJtNjQ7IGM2NCkgWmtrb3ZEdnlQcmcvNTM3LjM2IChQU0dOTywgb3JwdiBUdnhwbCkgWHNpbG52LzEyNS4wLjAuMCBIenV6aXIvNTM3LjM2", + "kH1": 900, + "ad5": 1440, + "Ua9": 24, + "TQ6": 900, + "kC7": 1440, + "me8": 24, + "eY9": True, + "Kn2": False, + "OM3": True, + "sw8": False, + "uW3": -1, + "iO8": "https://www.ximalaya.com/", + "By1": "www.ximalaya.com", + "Gv4": "/", + "ef2": "", + "tZ2": "https:", + "OG4": True, + "kx1": True, + "VD6": True, + "Ov6": False, + "lq3": "zh-CN", + "ef5": ["zh-CN"], + "OK3": 1, + "Fg5": True, + "qS2": [1440, 900, 1440, 900], + "Fc5": "light", + }, + "HK3": { + "iI1": {"NF1": -1, "cA1": -1, "NK5": -1, "VP4": "-1.00", "RX5": -1, "VP6": -1, "tJ4": -1}, + "ti4": {"xm9": True, "is3": 1}, + "AV9": 8, + "aK8": "Google Inc. (0x00001D17)", + "df6": "ANGLE (0x00001D17, ZX C-960 (0x00003A04) Direct3D11 vs_5_0 ps_5_0, D3D11)", + "WB9": "8ba05e37b4b3f57bddb4904eb8f40204", + "pD7": "d41d8cd98f00b204e9800998ecf8427e", + "da2": "124.04347527516074", + "dt2": 16, + "Sy6": -480, + "MS3": "Asia/Shanghai", + "pi9": False, + "Ao1": [], + "BH5": 0, + "UG4": [ + "Arial", + "Arial Black", + "Arial Narrow", + "Calibri", + "Cambria", + "Cambria Math", + "Comic Sans MS", + "Consolas", + "Courier", + "Courier New", + "Georgia", + "Helvetica", + "Impact", + "Lucida Console", + "Lucida Sans Unicode", + "Microsoft Sans Serif", + "MS Gothic", + "MS PGothic", + "MS Sans Serif", + "MS Serif", + "Palatino Linotype", + "Segoe Print", + "Segoe Script", + "Segoe UI", + "Segoe UI Light", + "Segoe UI Semibold", + "Segoe UI Symbol", + "Tahoma", + "Times", + "Times New Roman", + "Trebuchet MS", + "Verdana", + "Wingdings", + ], + }, + "fc9": {"cx4": "4g", "zY8": -1, "yj6": 1.5, "dV4": 200, "yX4": False}, + "adi": "070BF8:016D89:6BDE99:1600", + "acd": "D2t6yNNzRqtSbN4GtZw4eGLn8tfc0Y1EYzqzMOCd9GiGMX17", + "bdi": None, + "bcd": None, + "fd2": { + "Pf5": 1783179255284, + "Ja5": "070BF8:016D89:6BDE99:1600", + "xz7": "2ccd7426-e999-efba-78c5-bbea1fc69729", + "av1": "D2t6yNNzRqtSbN4GtZw4eGLn8tfc0Y1EYzqzMOCd9GiGMX17", + "cp9": 0, + }, + "exts": "", + "startime": 1783179258568, + "Zn6": { + "oe2": "0", + "EV9": "true", + "xu2": "1441A790908125E9682A828824A003E99783D166409BBC4DD782F461C2955BA7", + "CY8": "", + "nE4": 928, + "Tw1": [5799.099999904633, 6727.399999856949], + "Sb1": False, + }, + "jm9": 2, + "dla": "", + "swp": "", + "ecm": "", + "emm": "", + "asu": "", + "asu1": 0, + "GJ2": "31b33ada-785b-efc7-cbad-c985880ba125-fcs011", + "slw": "", + "bds": "", + "MT7": "33-00000-0000-1111111-000000-0011-000000-0000-00000-0", + "bnd": "-0", + "kec": "000000", + "BG5": False, + "Fd8": "1", + "iq7": False, + "DP5": "1441A7909C087DBBE7CE59881B9DF8B9", + "lL1": "63649ded5da25cce1ae7d5f2a950c18a", + "uT8": "-1", + "sV5": 2, + "Vo6": "", + "infoF": {"lof": False, "baf": False, "auf": False, "iif": False}, + "infoCallback": None, + "url_host": "hdaa.shuzilm.cn", + "_cidStorage": { + "_key": "cmci9xde", + "_value": "U2FsdGVkX19G9YbXMDClJDhN2IT4GtXDHuuLUlzrmuZDqisRyfuqlB9Im8cJ3oWqqi8rKpuwj6i0PFh2Ym6gmg==", + "_saveObject": True, + }, + "_pkgStorage": {"_key": "pmck9xge", "_value": "U2FsdGVkX19GQAJ5RgJtjxT5ljFskRYgi6bFYacB4yM=", "_saveObject": True}, + "_checkextensions": { + "_deviceInfoCollector": { + "Zf5": -1, + "GF9": "2.0.0", + "HW5": "", + "uS7": "", + "KFp": "", + "ew1": { + "Wg7": "", + "lV1": "", + "Xt4": "", + "yV2": "", + "KY1": "", + "Le3": "", + "kH1": -1, + "ad5": -1, + "Ua9": -1, + "TQ6": -1, + "kC7": -1, + "me8": -1, + "eY9": None, + "OM3": None, + "Kn2": None, + "OG4": None, + "kx1": None, + "VD6": None, + "Ov6": None, + "sw8": None, + "uW3": -1, + "lq3": "", + "ef5": "", + "iO8": "", + "By1": "", + "Gv4": "", + "ef2": "", + "tZ2": "", + "OK3": -1, + "Fg5": False, + "qS2": [], + "Fc5": "", + }, + "HK3": { + "iI1": {"NF1": -1, "cA1": -1, "NK5": -1, "VP4": -1, "RX5": -1, "VP6": -1, "tJ4": -1}, + "ti4": {"xm9": None, "is3": -1}, + "AV9": -1, + "aK8": "", + "df6": "", + "WB9": "", + "pD7": "", + "da2": -1, + "dt2": -1, + "Sy6": -1, + "MS3": "", + "pi9": None, + "Ao1": [], + "BH5": -1, + "UG4": [], + }, + "fc9": {"cx4": None, "ui9": None, "zY8": -1, "yj6": -1, "dV4": -1, "eN7": "", "yX4": False}, + "adi": "", + "acd": "", + "bdi": "", + "bcd": "", + "fd2": {}, + "exts": "", + "startime": 0, + "Zn6": {"oe2": "", "EV9": "", "xu2": "", "CY8": "", "nE4": -1, "Tw1": [], "Sb1": False}, + "jm9": "", + "dla": "", + "swp": "", + "ecm": "", + "emm": "", + "asu": "", + "asu1": 0, + "GJ2": "", + "slw": "", + "bds": "", + "MT7": "", + "bnd": "", + "kec": "", + "BG5": "", + "Fd8": "0", + "iq7": False, + "DP5": "", + "lL1": "", + "uT8": "-1", + "sV5": 1, + "Vo6": "", + "infoF": {"lof": False, "baf": False, "auf": False, "iif": False}, + "infoCallback": None, + "url_host": "hdaa.shuzilm.cn", + } + }, + "_idstor": {"_value": None}, + "_caddStorage": {"_key": "vmce9xdq", "_value": None, "_saveObject": True}, + "_ipfStorage": {"_key": "assva6", "_value": "U2FsdGVkX18v3Du2ZHJsNvvtzU+I7D8WBNBlFpRxpNw=", "_saveObject": True}, + "_ipflagStorage": { + "_key": "assva5", + "_value": "U2FsdGVkX1+PrpJkbR2dIWLeY/i1/2dGcYLQLh4eDtHqVRt/LkERQlZiSdem69zrRZOIHhv7pHPh3xL4SOxCsw==", + "_saveObject": True, + }, + "_checkintacts": {}, + "_checkdetects": {}, + "_getmousetest": {}, +} diff --git a/app/sdk/platforms/xm/music_info.py b/app/sdk/platforms/xm/music_info.py new file mode 100644 index 0000000..dc96d3d --- /dev/null +++ b/app/sdk/platforms/xm/music_info.py @@ -0,0 +1,84 @@ +"""喜马拉雅曲目信息(对应 sdk/xm/musicInfo.ts,PC 桌面端 baseInfo 通道)。 + +接口: https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/{ms_ts}?device=win&trackId=&trackQualityLevel=1 + +实测(2026-10-02)匿名免费曲目可用,但必须同时满足: + 1) 携带 PC 设备 Cookie(install_id 与 1&_device 同一 uuid,channel=99&100001,客户端 4.0.14 抓包格式) + 2) 携带 du_web_sdk 真实 xm-sign(hdaa 上报取 cadd/sid) +缺 Cookie 或换 device=www2 均返回 ret=1001 系统繁忙。 + +响应 trackInfo 顶层挂载(playUrlList/title/cover*/duration/isPaid/isAuthorized/isXimiUhqTrack), +playUrlList[].url 为 AES-ECB 加密地址(见 decoder.py)。 +""" + +from __future__ import annotations + +import time +import uuid +from typing import Any + +from app.sdk.auth_store import get_credentials +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import as_dict, as_list +from app.sdk.platforms.xm.sign import XM_UA, make_xm_sign + +BASE_INFO_URL = "https://www.ximalaya.com/mobile-playpage/track/v3/baseInfo/" + +#: 进程级设备标识(install_id 与 1&_device 共用同一 uuid,稳定复用) +DEVICE_UUID = str(uuid.uuid4()) +PC_DEVICE_COOKIE = f"install_id={DEVICE_UUID}; channel=99&100001; 1&_device=win32&{DEVICE_UUID}&4.0.14" + + +def build_request_cookie() -> str: + """登录态 Cookie(/login/xm 绑定,须含 1&_token);与 PC 设备 Cookie 合并后上送。""" + login_cookie = str((get_credentials("xm") or {}).get("cookie") or "") + return f"{login_cookie}; {PC_DEVICE_COOKIE}" if login_cookie else PC_DEVICE_COOKIE + + +def cover_url(track_info: Any) -> str | None: + info = as_dict(track_info) + path = info.get("coverMiddle") or info.get("coverLarge") or info.get("coverSmall") + if not path: + return None + text = str(path) + return text if text.startswith("http") else f"https://imagev2.xmcdn.com/{text}" + + +async def get_track_info(track_id: str, track_quality_level: int = 1) -> dict[str, Any]: + """拉取曲目播放信息(免登免费曲可用;付费/VIP 曲 ret=1001 或 isAuthorized=false)。""" + xm_sign = await make_xm_sign() + resp = await http_fetch( + f"{BASE_INFO_URL}{int(time.time() * 1000)}?device=win&trackId={track_id}" + f"&trackQualityLevel={track_quality_level}", + headers={ + "User-Agent": XM_UA, + "Accept": "application/json, text/plain, */*", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Referer": f"https://www.ximalaya.com/sound/{track_id}", + "Origin": "https://www.ximalaya.com", + "Cookie": build_request_cookie(), + "xm-sign": xm_sign, + }, + timeout=20_000, + ) + if resp.status_code != 200: + raise RuntimeError(f"baseInfo HTTP {resp.status_code}") + body = as_dict(resp.body) + ret = body.get("ret") + if ret != 0: + # 1001:匿名访问付费内容与风控共用;未登录时优先提示绑定登录 + logged = bool((get_credentials("xm") or {}).get("cookie")) + if ret == 1001 and not logged: + raise RuntimeError("该内容需要登录(POST /login/xm 绑定 cookie 后重试)") + raise RuntimeError(str(body.get("msg") or f"baseInfo ret {ret}")) + track_info = body.get("trackInfo") + if track_info is None: + track_info = as_dict(body.get("data")).get("trackInfo") + if not isinstance(track_info, dict): + raise RuntimeError("baseInfo 未返回 trackInfo") + return track_info + + +def play_url_list(track_info: dict[str, Any]) -> list[dict[str, Any]]: + """trackInfo.playUrlList 的安全读取(供取流使用)。""" + return [item for item in as_list(track_info.get("playUrlList")) if isinstance(item, dict)] diff --git a/app/sdk/platforms/xm/music_search.py b/app/sdk/platforms/xm/music_search.py new file mode 100644 index 0000000..0b4db93 --- /dev/null +++ b/app/sdk/platforms/xm/music_search.py @@ -0,0 +1,85 @@ +"""喜马拉雅搜索(对应 sdk/xm/musicSearch.ts,web 通道)。 + +接口: https://www.ximalaya.com/revision/search?core=track&kw=&page=&rows=&spellchecker=true&condition=relation&device=web +实测(2026-10-02)匿名可用(注意:同参数的 /revision/search/main 与 /search/main 会命中 +risk invalid 风控,本端点 + condition=relation&device=web 不受影响)。 + +返回 data.result.response.docs[],字段 id/title/duration(秒)/nickname/album_id/album_title/ +album_cover_path/is_paid/is_trailer。 +""" + +from __future__ import annotations + +import math +from typing import Any +from urllib.parse import urlencode + +from app.sdk.common.request import http_fetch +from app.sdk.common.utils import as_dict, as_list, format_play_time +from app.sdk.platforms.xm.sign import XM_UA + +SEARCH_URL = "https://www.ximalaya.com/revision/search" + + +def _normalize_cover(path: Any) -> str | None: + if not path: + return None + text = str(path) + return text if text.startswith("http") else f"https://imagev2.xmcdn.com/{text}" + + +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]: + del retry_num # 契约参数,web 搜索无重试语义 + params = urlencode( + { + "core": "track", + "kw": keyword, + "page": str(page), + "rows": str(limit), + "spellchecker": "true", + "condition": "relation", + "device": "web", + } + ) + resp = await http_fetch( + f"{SEARCH_URL}?{params}", + headers={ + "User-Agent": XM_UA, + "Accept": "application/json, text/plain, */*", + "Referer": "https://www.ximalaya.com/", + }, + timeout=15_000, + ) + body = as_dict(resp.body) + if resp.status_code != 200 or body.get("ret") != 200: + raise RuntimeError(str(body.get("msg") or "搜索失败")) + response = as_dict(as_dict(as_dict(as_dict(body.get("data")).get("result")).get("response"))) + docs = [item for item in as_list(response.get("docs")) if isinstance(item, dict)] + total = int(response.get("numFound") or len(docs)) + songs = [] + for item in docs: + duration = item.get("duration") + songs.append( + { + "songmid": str(item.get("id")), + "name": item.get("title") or "", + "singer": item.get("nickname") or "", + "albumId": str(item["album_id"]) if item.get("album_id") else "", + "albumName": item.get("album_title") or "", + "interval": format_play_time(float(duration)) if duration else 0, + "payPlay": bool(item.get("is_paid")), + "img": _normalize_cover(item.get("album_cover_path")), + "lrc": "", + "lrcx": "", + "types": [], + "_types": {}, + "disabled": False, + } + ) + return { + "allPage": math.ceil(total / limit) or page, + "total": total, + "limit": limit, + "source": "xm", + "list": songs, + } diff --git a/app/sdk/platforms/xm/sign.py b/app/sdk/platforms/xm/sign.py new file mode 100644 index 0000000..0f43f19 --- /dev/null +++ b/app/sdk/platforms/xm/sign.py @@ -0,0 +1,70 @@ +"""喜马拉雅 xm-sign 生成(对应 sdk/xm/sign.ts)—— du_web_sdk 签名链。 + +协议:device_info → JSON → URL 编码字节 → zlib(level 6) → AES-128-ECB(PKCS7, KEY) → + POST https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r=<uuid>(octet-stream)→ + Base64 响应 → AES-ECB 解密 → JSON 取 cadd/sid → xm-sign = "{cadd}&&{sid}" + +KEY 为 du_web_sdk _getDeviceKey(0) 的硬编码密钥;每次签名都重新上报(不缓存)。 +模板为纯 ASCII(Base64/十六进制字段),latin1 取字节与参考实现的 charCode 语义一致。 +""" + +from __future__ import annotations + +import base64 +import json +import time +import uuid +import zlib +from typing import Any + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from app.sdk.common.request import http_fetch +from app.sdk.platforms.kg.util import pkcs7_pad, pkcs7_unpad +from app.sdk.platforms.xm.device_info import DEVICE_INFO + +SIGN_KEY = b"m9ZtRrz:qujT8@da" +REPORT_URL = "https://hdaa.shuzilm.cn/report?v=1.2.0&e=1&c=1&r=" +#: 与 device_info.ew1.yV2(Chrome 125 模板)保持一致 +XM_UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36" +) + + +def _aes_ecb(data: bytes, decrypt: bool) -> bytes: + cipher = Cipher(algorithms.AES(SIGN_KEY), modes.ECB()) + if decrypt: + decryptor = cipher.decryptor() + return pkcs7_unpad(decryptor.update(data) + decryptor.finalize()) + encryptor = cipher.encryptor() + return encryptor.update(pkcs7_pad(data)) + encryptor.finalize() + + +async def make_xm_sign() -> str: + """生成 xm-sign(形如 "cadd&&sid");每次调用重新上报设备指纹。""" + info: dict[str, Any] = {**DEVICE_INFO, "Zf5": int(time.time() * 1000)} + raw = json.dumps(info, ensure_ascii=False, separators=(",", ":")).encode("latin-1") + payload = _aes_ecb(zlib.compress(raw, 6), False) + + resp = await http_fetch( + f"{REPORT_URL}{uuid.uuid4()}", + method="POST", + headers={ + "Content-Type": "application/octet-stream", + "User-Agent": XM_UA, + "Host": "hdaa.shuzilm.cn", + }, + body=payload, + timeout=15_000, + ) + text = resp.raw.decode("utf-8", errors="replace") if resp.raw else str(resp.body or "") + if resp.status_code != 200: + raise RuntimeError(f"xm-sign 上报失败: HTTP {resp.status_code}") + + decrypted = _aes_ecb(base64.b64decode(text), True).decode("utf-8", errors="replace") + obj = json.loads(decrypted) + cadd = str(obj.get("cadd") or "") + sid = str(obj.get("sid") or "") + if not cadd or not sid: + raise RuntimeError("xm-sign 上报响应缺少 cadd/sid") + return f"{cadd}&&{sid}" diff --git a/app/sdk/types.py b/app/sdk/types.py new file mode 100644 index 0000000..6909f22 --- /dev/null +++ b/app/sdk/types.py @@ -0,0 +1,122 @@ +"""SDK 共享类型约定。 + +Python 无编译期类型强制,这里的 TypedDict / Protocol 只作为迁移时的形状文档; +运行时行为与 TS 版一致(普通 dict)。 +""" + +from __future__ import annotations + +from typing import Any, Protocol, TypedDict, runtime_checkable + +SOURCE_IDS = ( + "kw", + "kg", + "tx", + "wy", + "mg", + "bd", + "joox", + "bili", + "5sing", + "apple", + "xm", + "me", + "qt", + "lr", +) + +QUALITIES = ("64k", "128k", "320k", "flac", "flac24bit") + + +class SongType(TypedDict, total=False): + type: str + size: str + + +class QualityInfo(TypedDict, total=False): + size: str + + +class SongInfo(TypedDict, total=False): + songmid: str + hash: str + copyrightId: str + songStringId: str + albumId: str + name: str | None + singer: str + source: str + interval: str | int + albumName: str + img: str | None + lrc: str | None + otherSource: str | None + types: list[SongType] + _types: dict[str, QualityInfo] + typeUrl: dict[str, str] + + +class MusicUrlResult(TypedDict, total=False): + type: str + url: str + via: str + encrypted: bool + fileKey: str + + +class LyricInfo(TypedDict, total=False): + lyric: str + tlyric: str + rlyric: str + yrc: str + + +class SearchResult(TypedDict, total=False): + list: list[dict[str, Any]] + allPage: int + total: int + limit: int + source: str + + +@runtime_checkable +class MusicSearchModule(Protocol): + async def search(self, keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> SearchResult: ... + + +async def search(keyword: str, page: int = 1, limit: int = 20) -> SearchResult: + """占位,供类型检查引用。""" + raise NotImplementedError + + +class PlatformModule(Protocol): + """平台模块契约(对应原 sdk/types.ts 的 PlatformModule)。 + + musicSearch / leaderboard / songList / hotSearch / singer / album / playlist / + parse / userPlaylist 为子模块对象;get_music_url / get_lyric / get_pic 为可等待方法。 + 各平台按能力自由实现,缺省表示不支持(/sources 的能力位据此判断)。 + """ + + musicSearch: Any + leaderboard: Any + songList: Any + hotSearch: Any + singer: Any + album: Any + playlist: Any + parse: Any + userPlaylist: Any + + +def cap(obj: Any, path: str | None = None) -> Any: + """按 `album.searchAlbum` 形式的嵌套路径取能力位(None 表示不支持)。""" + if obj is None: + return None + if not path: + return obj + cur = obj + for key in path.split("."): + cur = getattr(cur, key, None) if not isinstance(cur, dict) else cur.get(key) + if cur is None: + return None + return cur diff --git a/docs/PORTING.md b/docs/PORTING.md new file mode 100644 index 0000000..7556100 --- /dev/null +++ b/docs/PORTING.md @@ -0,0 +1,239 @@ +# 平台移植契约 + +本文件是 TypeScript → Python 迁移的唯一契约来源。每个平台包都必须按此实现, +**不要把 TS 机械直译**:各平台方法签名差异很大(见「签名差异」一节),逐个核对。 + +## 1. 目录与命名 + +| TypeScript | Python | +| --- | --- | +| `sdk/kw/index.ts` | `app/sdk/platforms/kw/__init__.py` | +| `sdk/kw/musicSearch.ts` | `app/sdk/platforms/kw/music_search.py` | +| `sdk/kw/hotSearch.ts` | `app/sdk/platforms/kw/hot_search.py` | +| `sdk/kw/songList.ts` | `app/sdk/platforms/kw/song_list.py` | +| `sdk/apis/kw.ts` | `app/sdk/apis/kw.py` | +| `sdk/5sing/*` | `app/sdk/platforms/fivesing/*` | +| `sdk/auth/wy.ts` | `app/sdk/auth/wy.py` | +| `sdk/bili/login.ts` | `app/sdk/auth/bili.py` | + +命名规则:文件名/属性名 camelCase → snake_case;类名保持 PascalCase。 +平台 id 对外仍是两字母短码(`kw kg tx wy mg bd joox bili 5sing apple` + 有声 `xm me qt lr`), +包目录名用 `fivesing`(Python 标识符不能以数字开头),但 `MODULE.id` 必须是 `"5sing"`。 + +## 2. 平台模块入口 + +每个 `app/sdk/platforms/<pkg>/__init__.py` 导出 `MODULE`: + +```python +from app.sdk.platforms._base import Platform +from app.sdk.platforms.kw import album, hot_search, leaderboard, lyric, music_search, pic, playlist, song_list + +MODULE = Platform( + id="kw", + name="酷我音乐", + music_search=music_search, # 子模块对象(有 .search 方法) + leaderboard=leaderboard, # .get_boards() / .get_list(bangid, page) + song_list=song_list, # .get_list(sort_id, tag_id, page) / .sort_list + hot_search=hot_search, # .get_list() + album=album, + playlist=playlist, + get_lyric=lyric.get_lyric, # 顶层方法直接挂函数 + get_pic=pic.get_pic, +) +``` + +`get_music_url` **不要**挂在平台模块上,它属于 `app/sdk/apis/<pkg>.py`; +`app/sdk/__init__.py` 的 `_quality_table()` 从 apis 模块读 `SUPPORT_QUALITYS`。 +若平台需要对外暴露 `get_music_url`(如 `/sources` 的 `url` 能力位), +在 `Platform(...)` 里传 `get_music_url=apis.get_music_url`。 + +**缺省即不支持**:没有的成员不要写 `None` 占位,直接省略。`/sources` 与路由 404 都据此判断。 + +## 3. 子模块协议 + +```python +# music_search +async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict +# → {"list": [SongInfo, ...], "total": int|None, "limit": int, "source": str, "allPage": int|None} + +# leaderboard +async def get_boards() -> dict # → {"list": [{"id", "name", "bangid"}]} +async def get_list(bangid: str, page: int = 1) -> dict +# → {"list": [SongInfo], "total": int|str|None, "page": int, "limit": int, "source": str} + +# song_list +async def get_list(sort_id, tag_id, page: int = 1) -> dict +sort_list: list[dict] # 可选:[{"id","name"}],路由取首项作默认排序 + +# hot_search +async def get_list() -> dict # → {"list": [str], "source": str} + +# singer +async def get_info(singer_id: str) -> dict +async def get_song_list(singer_id: str, page: int = 1, limit: int = 20) -> dict +async def get_album_list(singer_id: str, page: int = 1, limit: int = 20) -> dict + +# album +async def get_album_detail(album_id: str, page: int = 1, limit: int = 20) -> dict # 优先 +async def get_album_list_detail(album_id: str, page: int = 1) -> dict # kw 用这个(第 2 参是页码,第 3 参是重试次数,不要透传 limit) +async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict | list + +# playlist +async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 20) -> dict +async def get_playlist_songs(playlist_id: str) -> list # 可选,详情签名不支持分页时的兜底 +async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict | list +async def get_categories() -> dict | list +async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict | list + +# parse +async def parse_song(link: str) -> dict +async def parse_album(link: str) -> dict +async def parse_playlist(link: str) -> dict + +# user_playlist +async def get_list(page: int = 1, limit: int = 20) -> dict | list +``` + +返回裸数组也可以(路由会把 `list` 与 `total` 补齐),但**能返回完整结构就返回完整结构**。 + +## 4. 顶层方法 + +```python +async def get_music_url(song_info: dict, quality: str, retry_num: int = 0) -> dict +# → {"type": <实际命中音质>, "url": <播放地址>} +# 可选附加:{"encrypted": True, "fileKey": "..."} —— mg 的 Z3D/I3D 加密流 + +async def get_lyric(song_info: dict) -> dict # → {"lyric", "tlyric", "rlyric"?, "yrc"?} +async def get_pic(song_info: dict) -> str # → 封面 URL +async def get_music_info(song_info: dict) -> dict # 可选 +async def handle_music_info(song_info: dict) -> dict # 可选:补全 name/singer/img/albumName +``` + +`song_info` 是普通 dict,路由传入的关键字段: +`{"songmid": id, "hash": id, "copyrightId": id, "albumId": album_id or "", "name": ..., "singer": ..., "interval": ..., "_types": {}}`。 +平台按需读取,缺失就用空值兜底,**不要假设字段一定存在**(原 TS 同样如此)。 + +## 5. 请求层用法 + +```python +from app.sdk.common.request import http_fetch + +resp = await http_fetch(url, headers={...}) # 默认 GET +resp = await http_fetch(url, method="POST", form={...}) # urlencoded +resp = await http_fetch(url, method="POST", body={...}) # JSON +resp = await http_fetch(url, timeout=10_000) # 毫秒,与原文一致 + +resp.status_code # int(Node 的 statusCode) +resp.body # 已解析:JSON → dict/list,否则 str +resp.raw # 原始 bytes +resp.header("set-cookie") +``` + +与 TS 的差异(迁移时必改): + +| TS 写法 | Python 写法 | +| --- | --- | +| `const { promise, cancelHttp } = httpFetch(...)` `await promise` | `await http_fetch(...)` | +| `.then(({ body }) => ...)` | `resp = await http_fetch(...)`; 用 `resp.body` | +| `new Promise(...)` 包一层 | 直接 `async def` | +| `Promise.all(tasks)` | `await asyncio.gather(*tasks)` | +| `Promise.allSettled` | `asyncio.gather(..., return_exceptions=True)` | +| `setTimeout` | `await asyncio.sleep(ms / 1000)` | +| `Buffer.from(x, 'hex')` | `bytes.fromhex(x)` | +| `Buffer.from(x).toString('base64')` | `base64.b64encode(x).decode()` | +| `crypto.createHash('md5')` | `hashlib.md5(...).hexdigest()` | +| `crypto.createHmac('sha256', k)` | `hmac.new(k.encode(), msg.encode(), hashlib.sha256)` | +| `crypto.createDecipheriv('aes-128-ecb')` | `from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes` | +| `crypto.randomUUID()` | `uuid.uuid4().hex` / `str(uuid.uuid4())` | +| `zlib.inflateSync/inflateRawSync` | `zlib.decompress(data, 15 + 32)` / `zlib.decompress(data, -15)` | +| `iconv-lite.decode(buf, 'gbk')` | `buf.decode('gbk', errors='replace')` | +| `qrcode` 生成二维码 | `app/sdk/common/qrcode.py` 的 `to_data_url(text)` | +| `Date.now()` | `int(time.time() * 1000)` | +| `Math.random()` | `random.random()` | +| `JSON.stringify(x)` | `json.dumps(x, ensure_ascii=False, separators=(",", ":"))` | +| `Object.keys(a).sort().join('&')` | `"&".join(f"{k}={v}" for k, v in sorted(a.items()))` | + +**Node 内置模块只有这四处**,其余平台不需要额外依赖: +`crypto`(`apis/kw.ts`、`kw/util.ts`、`wy/utils/crypto.ts`、`sdk/utils.ts`)、 +`zlib`(`tx/qrcDecode.ts`)、`iconv-lite`(`kw/lyricDecode.ts`)、`qrcode`(`bili/login.ts`)。 + +## 6. 工具函数 + +`app/sdk/common/utils.py`:`size_formate` / `format_play_time` / `date_format` / `date_format2` / +`format_play_count` / `decode_name` / `format_singer_name` / `pick` / `to_md5`。 + +平台模块里 `import { sizeFormate } from '../common/utils.ts'` 对应: + +```python +from app.sdk.common.utils import size_formate +``` + +`sdk/utils.ts` 里的 `formatSinger` 对应 `format_singer_name`(签名:`(singers, name_key="name", join="、")`)。 + +## 7. 凭据 + +```python +from app.sdk.auth_store import get_credentials, set_credentials, get_kg_auth, get_tx_auth, get_cookie + +get_cookie("xm") # 空字符串表示未登录 +get_kg_auth() # {"token", "userid"} 或 None +get_tx_auth() # {"cookie", "uin"} 或 None +``` + +## 8. 错误 + +平台内部抛 `RuntimeError("上游返回: ...")` 即可,路由层统一转 502。 +**不要抛 `ApiError`**(那是路由层的),除非确需指定状态码(如登录失效 401)。 + +平台内部对「可降级的失败」(换音质档、换接口、换 CDN)必须自己 catch 后重试, +不要让它冒泡成 502 —— 与原始实现的降级链一致。 + +## 9. 签名差异(逐个核对,别批量替换) + +| 平台 | 取流 id | 特别之处 | +| --- | --- | --- | +| kw | `songmid` | `apis/kw.py` 双通道:mobi.s(主,需校验 bitrate 精确命中 + 公开格式白名单)→ web playUrl(需动态 cookie 算 Secret 头)。CDN 返回的 query 用 `$` 顶替 `=`,必须还原。 | +| kg | `hash` | 取流需 `encode_album_audio_id`;有 `login.token` 时走 VIP 通道。歌词是 KRC 需解密(`krcDecode.ts`)。 | +| tx | `songmid` → 需先查 `songmid → media_mid` | 音质候选表 + 登录态分支;歌词 QRC 需解密(`qrcDecode.ts`,zlib inflate)。 | +| wy | `songmid` | 取流需 eapi 加密(`wy/utils/crypto.ts`);未登录走匿名接口。 | +| mg | `copyrightId` | 加密流:返回 `{"encrypted": True, "fileKey": ...}`,路由转 `/stream/mg/<票据>`。解密逐字节 `plain[i] = cipher[i] - key[i % 32]`。 | +| bd | `songmid` | 多 CDN 轮询(失败换下一个)。 | +| joox | `songmid` | 需 JOOX 客户端签名头。 | +| bili | `bvid` / `aid` | 取流需 `cid`,且必须带 Referer/UA。音质档位是 `qn` 整数(不是字符串)。 | +| 5sing | `songId` + `songType` | `songType`:`fc`=翻唱、`yc`=原唱、`bz`=伴奏。音质是 `sq/hq/lq` 不是 `flac/320k/128k`。 | +| apple | `songmid` | 只有 preview 试听片段(完整曲 DRM)。 | +| xm | `trackId` | 需设备指纹 + 签名(`xm/sign.ts`)。付费内容需 cookie。 | +| me | `soundId` | App API 签名(`me/sign.ts`),cookie 里的 `token` 自动带入签名。 | +| qt | `channelId:programId` 复合 id | 见 `/album/qt` 返回的 id。 | +| lr | `bookId:section` 复合 id | 见 `/album/lr` 返回的 id。必须登录态,免费章节也需要。 | + +## 10. 验收要求 + +每个平台移植完成后,**必须真实联网验证**(不靠 mock): + +```bash +uv run python -c " +import asyncio +from app.sdk.platforms.kw import MODULE +async def main(): + r = await MODULE.music_search.search('稻香', 1, 5) + print(len(r['list']), r['list'][0]) +asyncio.run(main()) +" +``` + +至少覆盖:搜索 → 取流 → 歌词 → 封面;有榜单/歌单/专辑能力的再各验一次。 +取流验证要打印 URL 并实际 `GET` 前 4 字节确认是音频(`ID3`/`fLaC`/`OggS`/`ftyp`)。 +上游接口失效时如实报告并写进注释,**不要用假数据强行通过**。 + +## 11. 规范 + +改完必须两条都过: + +```bash +uv run ruff check app && uv run ruff format --check app +uv run pyright app/sdk/platforms/<pkg> app/sdk/apis/<pkg>.py +``` + +`ruff` 规则启用 `E,F,W,I,UP,B,ASYNC,RUF`,行宽 120,双引号,`target-version = py312`。 +注释用中文,与既有代码风格一致;只保留解释「为什么」的注释,不复述代码。 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a4add77 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "music-server" +version = "1.0.0" +description = "多平台音乐聚合服务器" +requires-python = ">=3.12,<3.13" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "aiohttp>=3.10", + "brotli>=1.1", + "cryptography>=43", + "qrcode>=8", + "pillow>=10", +] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-asyncio>=0.24", + "ruff>=0.6", + "pyright>=1.1.380", + "httpx>=0.27", +] + +[tool.ruff] +line-length = 120 +target-version = "py312" +src = ["app", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "BLE", "ASYNC", "RUF"] +ignore = [ + "RUF001", # 中文全角标点在文案里是刻意用法 + "RUF002", + "RUF003", + "E501", # 行宽由 formatter 决定,长 URL/中文注释不强拆 +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["B011"] + +[tool.ruff.format] +quote-style = "double" + +[tool.pyright] +include = ["app", "tests"] +exclude = [".venv", "tmp"] +pythonVersion = "3.12" +typeCheckingMode = "basic" +reportMissingTypeStubs = false +reportUnknownMemberType = false +reportUnknownVariableType = false +reportUnknownArgumentType = false +reportUnknownParameterType = false +reportMissingParameterType = false +reportUntypedFunctionDecorator = false +reportUnnecessaryIsInstance = false + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +filterwarnings = ["ignore::DeprecationWarning"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4c1cc26 --- /dev/null +++ b/uv.lock @@ -0,0 +1,836 @@ +version = 1 +revision = 5 +requires-python = "==3.12.*" + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/81/1546c983678a9b4e77cb4718eaedd1b2caf24a6cdbf1eb8775938d22aff7/brotli-1.2.1.tar.gz", hash = "sha256:02b2f7b5a83485efe448a44ddf7983def05c3bb1dcf917d4f7154070260e13de", size = 7389447, upload-time = "2026-10-22T14:00:12.66Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/5d/dcf82117cbb506cfe0b75a00c34f2f7e850e5d933d80c0e50e2a191f1fee/brotli-1.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3975fb11f63e212b87a087e4a910187b522c6969a4989ca362d6f85f9dc36acd", size = 861663, upload-time = "2026-10-22T13:57:54.196Z" }, + { url = "https://files.pythonhosted.org/packages/98/c3/59d6445882214a47eab34e763aacc8dce18c4e7f9c5e2d9c95df9a7f7a3d/brotli-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de499e2492c8106a5e163d3cf5b6726e2797f9234cfbc6055fbb5f9d8f198f39", size = 442271, upload-time = "2026-10-22T13:57:55.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/7b/55b0c895c17dbe747b63e442d2c3a163cc838d116bf5560414a13cc284c9/brotli-1.2.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:1f65a91f3576949793b8720b12a47e7ea06debc970a2da6d54ff8458053e2109", size = 1242808, upload-time = "2026-10-22T13:57:56.675Z" }, + { url = "https://files.pythonhosted.org/packages/7a/75/d8f2a81748b60733e8b03afd4bc1b70d3fb74a235b3b13dff0b2978e7f87/brotli-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:875975f91870682cca4371260522ad3f22d636e845b5c5355c52bc045c0f75c8", size = 1402417, upload-time = "2026-10-22T13:57:57.827Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b2/7addcc2c4965d0946e38a416b60af89700d54141845447f03fe4bff0293c/brotli-1.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c3f667dc60a875b12a5ecd7828062ccd97c0fb79c4adc6c8699d23d5ff2af7e1", size = 1424422, upload-time = "2026-10-22T13:57:59.285Z" }, + { url = "https://files.pythonhosted.org/packages/b6/cc/b0b5b6b1ff4544bec3a702d5ad2e0badf22db296d16277a6ee59dabaffdc/brotli-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4b9dd0173ca5e01d1eb307d3c184fe8c09bdc168ede560889b1018d216d38c61", size = 1420478, upload-time = "2026-10-22T13:58:00.484Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/1d9bdb8aa35c14ba45ce208768eb977810446b7537d9d418c2bcaae2ed20/brotli-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9307679936e86d7773eadfa444a4a4ee80f6f45230e93379875f4a6897a09a2b", size = 1485966, upload-time = "2026-10-22T13:58:01.715Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8e/86e64fc403888244e090a7a3d9fb35f6d6bb2219e789fc88064065464a3c/brotli-1.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:fb5df75d1b1c42d997adbb39eac61f2cdb48efa26304a0cb9c0b757c9bc20732", size = 1279161, upload-time = "2026-10-22T13:58:02.933Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ba/cbfa7d264d4cec16d5951071f1d04028e7024119c65a7bd909e77cafc9e6/brotli-1.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d6ef0035b6ae338a46951c144081c61fb044f4c1eb6d20acc5ba6277f666a4c0", size = 1594070, upload-time = "2026-10-22T13:58:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/7abc13404dde1665d2fe3cde3385e06d5f81aed0242c23979acc3f552afd/brotli-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:338856ae9ff63af4fc3dbbd6184a7276a6ae83df2243562e86e5ee5cf980cce7", size = 1489507, upload-time = "2026-10-22T13:58:05.591Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f6/8eacbc45a0a158897752fae9055a4d4a0cfdb1c8e15680d1815af3936da9/brotli-1.2.1-cp312-cp312-win32.whl", hash = "sha256:96e6966edcfae74ffac2522d2dd1a304450d18807a9791f5ed0a3232192c9437", size = 333017, upload-time = "2026-10-22T13:58:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/3e/82/b7f6c56393f2224ff4108cf1cc255921be1c0ae5962163edbe367c8a55bc/brotli-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:f5342b8256476554b4045db8b7bf4dc4044d0405485f220f0cd80764bc0d7faf", size = 371081, upload-time = "2026-10-22T13:58:08.082Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6d/eff775fb2de444f224aa52f79ba400ff963377436495c3f01ed52c3071dd/brotli-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:b0c32f0660bb21e810ffc1d3d04b6ea682e118b8141c6e12522a3dacadeb70ee", size = 380894, upload-time = "2026-10-22T13:58:09.157Z" }, + { url = "https://files.pythonhosted.org/packages/f5/29/62f7de3d08c53b09fe67b24b024b13ce3ec088fe0dd6940064719e61eba5/brotli-1.2.1-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:5f3da70516b1bd1b2aa183ec4ab5c02dfc6ad5c1184d47bb65ade25e710439b6", size = 416714, upload-time = "2026-10-22T14:00:06.054Z" }, + { url = "https://files.pythonhosted.org/packages/e2/66/3da6c4e928c55fc85f4cdaf0cbd41ebb1639ae70c71f9f26183818460d54/brotli-1.2.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:11aec6ea5c20cad61a33302e06baa48b0243bfa4d173bc2985dabd047e367d08", size = 399657, upload-time = "2026-10-22T14:00:07.095Z" }, + { url = "https://files.pythonhosted.org/packages/85/9d/626bb730a70507151667d950c9214003427b962a3876142c07ba52d96391/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00a50d063aae2e69c2eb28818ccf6dc3e51f2ea5b74e3c2082a47943ee23f399", size = 404727, upload-time = "2026-10-22T14:00:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/bb/34/fb0bbe56d822ee4c75b2383e518bdd579327f18f1cfedf1d779a585df01b/brotli-1.2.1-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37de1818ce469b3c37324c89d4d4dbb5e64461d9dc01c16f8abb2a17c465b132", size = 398927, upload-time = "2026-10-22T14:00:09.739Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3f/43ea0a58dcb50c820465a0d62ccbbf6464c2e2cc4ebf582e64225dd67b4b/brotli-1.2.1-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:06a22fcbcae059c0679098232779095fc50a735a3fea02db5c79cab10b84d877", size = 457800, upload-time = "2026-10-22T14:00:10.978Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/11/b524cbd9bbdaca58608e4cf4053fd463417549e1959fa8d3aa18319f9555/certifi-2026.10.23.tar.gz", hash = "sha256:a9895e6e2758b371e0545e321a3a8fd1fcfd9829ada8824a936dfb16aaaf36cd", size = 136225, upload-time = "2026-10-23T11:36:16.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" }, + { url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" }, + { url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" }, + { url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" }, + { url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" }, + { url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" }, + { url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" }, + { url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" }, + { url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" }, + { url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" }, + { url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" }, + { url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" }, + { url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" }, + { url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" }, + { url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" }, + { url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" }, + { url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" }, +] + +[[package]] +name = "fastapi" +version = "0.161.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ed/3d/2cc1a0dbc2dac6e157b4ef12bac063e1016cb7c6d7395f76665e439cf1aa/fastapi-0.161.1.tar.gz", hash = "sha256:4076b88434452e20289379068e976c4a04c65ffc8e6be31510106e150a422f0f", size = 527406, upload-time = "2026-10-22T22:22:07.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/2a/101012d82931d9de3855384e1667e49c0e07a0048e3868f9fec1697293a9/fastapi-0.161.1-py3-none-any.whl", hash = "sha256:a1363080cf9aeedecd99f4e1a23669d7e77542227f6536f4d7bbd59f07ecaa94", size = 149049, upload-time = "2026-10-22T22:22:06.139Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/ec/deed52912ab7ca6c0b12859330c571c60c61d7267b341b28951fcbf13694/httptools-0.9.0.tar.gz", hash = "sha256:d484ebb7e3a3f3597b0f645fbd1b85633674ca808c1f5ba11c2caf7c66f5c8b6", size = 282523, upload-time = "2026-10-09T19:57:04.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/ed/0916b8b7ebd1deeaf22acba71b68c57b4b6b69aa1918f3812dea208b4276/httptools-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f9ccc9884241efceb4547a92955d128574c864681f11b7ea3ecbde295fafbe8b", size = 119039, upload-time = "2026-10-09T19:54:32.556Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0b/9b6de4a01a563a904d0826c9069c824b330e1816df26c9bdf93f60b50857/httptools-0.9.0-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:45b3002392948dcf578029c89f6318e1289a993a1a5ec38a4161560fab60f811", size = 115051, upload-time = "2026-10-09T19:54:33.908Z" }, + { url = "https://files.pythonhosted.org/packages/85/3f/642113e9882f53158ecddf58003d25f18ded2c210ed23bf6eb663d4d51c3/httptools-0.9.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3e3201fe4d46e0d15d7ff9fafc94a605da9eb82d2c5b9837f0368acb325481f1", size = 552848, upload-time = "2026-10-09T19:54:35.434Z" }, + { url = "https://files.pythonhosted.org/packages/95/4c/3ecc59c99c28652d8d08d9b5be65770a14d2cadc616dad94224cee2b0e7e/httptools-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58a1b0ec4cbb930e69669f9771715b2c7898d3cdf064d9811f7a66afef96b544", size = 549181, upload-time = "2026-10-09T19:54:37.099Z" }, + { url = "https://files.pythonhosted.org/packages/43/ce/21f5b2759590b7054e38d3b704a3c6b853c3395c370b3d6f16c45aea0fc0/httptools-0.9.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c58dc91aefb31adad500aa68054334f429b840b36dd29e34e834101044cb2ef", size = 569121, upload-time = "2026-10-09T19:54:38.772Z" }, + { url = "https://files.pythonhosted.org/packages/52/c3/7c523aa8d0fa7a57010a3e1bbdebc209585009076465f3d1ae6a3f54b814/httptools-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b900073e7b8481ef1aaf4f6c1789d210a1db01a9da8789821578cfeb4c2d540", size = 487649, upload-time = "2026-10-09T19:54:40.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/e2/d90d60002692b8afcbc06fb49ca3a4365b32abed6c40fb2b612c67721a00/httptools-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6c12d0393a903b58bc5f5a7406d6c5290acfb8284290d68547ce620c06f7d133", size = 529583, upload-time = "2026-10-09T19:54:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/19172874cde0344a20c85877a0b2d0dcfca31111729ad8a79e8b4ac4e207/httptools-0.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:29b0d823e3c1e7cd1093a5dc889245db693ef13ada624cd66e2262421ef38867", size = 552414, upload-time = "2026-10-09T19:54:44.19Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b8/02ea7910f69e5371986b025fb3b410592106df54e977a5732fd1d95917b5/httptools-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6ebd39ee26db460cfe5ab8b71a15d1149b289139a0d3981522757d6af620887e", size = 482400, upload-time = "2026-10-09T19:54:46.064Z" }, + { url = "https://files.pythonhosted.org/packages/de/97/f05eac916d44cbbfe43668a6a40ab93e7fd8f94d5120d1ce2d8e55c69871/httptools-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4efbee349138a3fee7a4cc3a95abd2d499fae70dd5bff9fed9138d6f570f4283", size = 536148, upload-time = "2026-10-09T19:54:47.695Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e9/9435dfcb7f1a1d6ebdc79a902164dfca70e33774c80bb60d25c630c31ef1/httptools-0.9.0-cp312-cp312-win32.whl", hash = "sha256:36fac804b8cfd6b935ae64f71349f833d2b6298404626d017a2c57bb942bc643", size = 86274, upload-time = "2026-10-09T19:54:49.1Z" }, + { url = "https://files.pythonhosted.org/packages/8b/69/813f1bf90be507d4166c437be1a413574d0e0abf36e2fec10c266661b0ee/httptools-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:7e32b83bd8c2f8b6fa726ef34e63e21c4d7eddc277d40d4ef7245ea3ed28e5b6", size = 92446, upload-time = "2026-10-09T19:54:50.498Z" }, + { url = "https://files.pythonhosted.org/packages/ae/e0/1d29e328c4cafe843403341e1455e0aec18b0e6910fbb14f12b36b563f19/httptools-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:813a32f94991b9627795528053c73a57d2ce3eb98ede89f0e1c7a31095938e81", size = 88676, upload-time = "2026-10-09T19:54:51.844Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "multidict" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341, upload-time = "2026-10-09T20:31:38.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e1/215e7df354e2907f3af9d910c8136653cfec94796012a776359bc802a326/multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21", size = 165426, upload-time = "2026-10-09T13:59:50.564Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ec/461ba588b308ada2cd16907d6ea425ca4d417596b88c12814ad9a0bb7325/multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4", size = 95943, upload-time = "2026-10-09T13:59:52.381Z" }, + { url = "https://files.pythonhosted.org/packages/c9/84/31444ef07ec13a33c42c2772d986129e137e69c4beb89ca64f2138988145/multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d", size = 98294, upload-time = "2026-10-09T13:59:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/dc/10/aca13806d73d88b5b01e35e828e23a346cb1abad710a49dff0507702efc9/multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88", size = 453301, upload-time = "2026-10-09T13:59:55.397Z" }, + { url = "https://files.pythonhosted.org/packages/96/8c/382d771bfb3a9282d98332d0e1f27fd1f8b4ae0e7175bd7e008ebf934900/multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698", size = 478570, upload-time = "2026-10-09T13:59:56.899Z" }, + { url = "https://files.pythonhosted.org/packages/52/9c/e81b0c92449da3a1950a575c520d957d7be618777d170717a71e00558d7f/multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711", size = 443699, upload-time = "2026-10-09T13:59:58.465Z" }, + { url = "https://files.pythonhosted.org/packages/63/72/f8f5fee6960d1b580a7b046c7c5abccf67aa26fa5647927a91c9c835c2f8/multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412", size = 497332, upload-time = "2026-10-09T14:08:01.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/a25d7db3ca451b588e743e067ee80f2b475edf6467a56908454faf6a714c/multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda", size = 512457, upload-time = "2026-10-09T14:08:25.465Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/374c0ab122bb98b1a62a8742b1e3f59563e4d609942005a4041861a0df67/multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01", size = 483000, upload-time = "2026-10-09T14:08:27.132Z" }, + { url = "https://files.pythonhosted.org/packages/46/1f/01c8522859771dc3cee840d5852233fe84c91aa982a1cd8ad594306d25cc/multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c", size = 430746, upload-time = "2026-10-09T14:08:28.715Z" }, + { url = "https://files.pythonhosted.org/packages/ab/f3/af90affc8cca6b4ee59b2ec354a20839005e829d14d155451009810ef1ce/multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593", size = 466859, upload-time = "2026-10-09T20:27:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/f8/97/1b6762f37f6331449e164af9d5500f0abb0f23670e81ba543441e0a6be1d/multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c", size = 441952, upload-time = "2026-10-09T20:27:26.559Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d2/4908177fbf22438799c04ba11a2853a99c69d028fccefe61f19e68caba0c/multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c", size = 452810, upload-time = "2026-10-09T20:27:28.347Z" }, + { url = "https://files.pythonhosted.org/packages/8d/05/5031f44f680ec54fc182c4d71c9ab7c07216983946aa64ce6fdd56a52692/multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a", size = 483934, upload-time = "2026-10-09T20:27:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/da/3b/9b21d107dbe96fa7e6966ff9e5e10b9ac0d2d1c2cf1633098e4c700f865e/multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be", size = 427042, upload-time = "2026-10-09T20:27:32.433Z" }, + { url = "https://files.pythonhosted.org/packages/53/ce/5b01b1041580072866b30e39c6380bff269e72fb5ca41a7f2fb828ede943/multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf", size = 492543, upload-time = "2026-10-09T20:27:34.459Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6f/6508a23fcc7b1122e4f18409d7900ffeb3cd020cd080fe98aba3eabf9486/multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de", size = 475796, upload-time = "2026-10-09T20:27:36.289Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b6/ebb6433f4aa55ac1fea4bf11e860e99611640d07cc92d21cb3f35608de22/multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb", size = 81250, upload-time = "2026-10-09T20:27:38.229Z" }, + { url = "https://files.pythonhosted.org/packages/61/0a/11240e5e7d2e986e288f4a6090f569ad00225b7c2c513d4096b36643f9ab/multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085", size = 93817, upload-time = "2026-10-09T20:27:39.739Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9b/a04ffd76db7cca2a60d347e839315e1ada63017c6c343fa90a23a5a55433/multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d", size = 92312, upload-time = "2026-10-09T20:27:41.285Z" }, + { url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087, upload-time = "2026-10-09T20:31:35.945Z" }, +] + +[[package]] +name = "music-server" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "brotli" }, + { name = "cryptography" }, + { name = "fastapi" }, + { name = "pillow" }, + { name = "qrcode" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "pyright" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = ">=3.10" }, + { name = "brotli", specifier = ">=1.1" }, + { name = "cryptography", specifier = ">=43" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "pillow", specifier = ">=10" }, + { name = "qrcode", specifier = ">=8" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pyright", specifier = ">=1.1.380" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "nodeenv" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/105de02c1322cfada6d9710d9146ef8026419d433c9d08359a2d35805811/nodeenv-1.11.0.tar.gz", hash = "sha256:3ce8fe5b71d16e8af7039ca65257354100bc772965d6bc549070649e53b1b146", size = 99301, upload-time = "2026-09-26T11:29:21.367Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl", hash = "sha256:edaa16e6c14d7cf395d75d4bbd5a26390f4dc06501a33b4e76282b02cc688a25", size = 34277, upload-time = "2026-09-26T11:29:19.933Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pillow" +version = "13.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/a4/9cc30690a394a8305e8be4c9036aa16d0dfddadd09079860b582f562496d/pillow-13.0.0.tar.gz", hash = "sha256:cdcd1ba258178f7356d74170c9a81ba4eab6a5d55748bc402a5a2a6fe6be3510", size = 47058631, upload-time = "2026-10-16T22:18:09.718Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/b8/58d79332229482eb0d27c82973850808c19d37e5bdc14443d3417bd373f5/pillow-13.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:67c8d44ad89fa7eb88de5d6ef6199b1e03dd24974f85a67639fc8badb801fe79", size = 5568643, upload-time = "2026-10-16T22:15:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/39/4b/f2ed53d83aa44ac19599ac0a54897f867fafb95f406980d1e24083f8cbe3/pillow-13.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc481f742ccda20e9ed760b8f978dff50f2a4e9bc253ac167fb0a05b0783ef52", size = 4852329, upload-time = "2026-10-16T22:15:46.284Z" }, + { url = "https://files.pythonhosted.org/packages/3b/bb/9e30e8ed18c20800ee327a12107d5249ad8dc444867b3adfcc21654e3c71/pillow-13.0.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8bf295fe459343bc6093c8e5e0df60bfa189e0fcb775a22e2c2488fdeccc6c0d", size = 6519309, upload-time = "2026-10-16T22:15:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e6/99/fdf65cc539e266ab93546c261bdf98411fe165c6e4e13f9dbf597097192b/pillow-13.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:42d63be3af4f9b951b199b0a9d23365d37ab14687e6d77b74940432c8515c4ca", size = 7144542, upload-time = "2026-10-16T22:15:50.093Z" }, + { url = "https://files.pythonhosted.org/packages/26/9b/f38b7c4d8f599537e2d1a6d546cec9beef7536ff8f5c49a3f9c78179d8ac/pillow-13.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4bd024d567eca931b668978d672dba914d4b8e2628e2c3073a2ad178de8a2620", size = 6618042, upload-time = "2026-10-16T22:15:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a8/7c/45ee28531f89ee4582967d8a17f7a26d71ca64cd902b93c269590cd60a00/pillow-13.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23edf4ec4b6f4f240e8df6e4985e9ee2ced86474632433e55fb1e2548744bf40", size = 7228137, upload-time = "2026-10-16T22:15:54.009Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/9c2a969cb1b70201d04fa17fd5aedf02a732870b8da86bceaaa30b0ca385/pillow-13.0.0-cp312-cp312-win32.whl", hash = "sha256:02464da86f561f410e13b061ec37c3068a9cb557a095494157742d07b3c24a27", size = 6515423, upload-time = "2026-10-16T22:15:56.25Z" }, + { url = "https://files.pythonhosted.org/packages/ff/cd/73a79ecdc822076002eb24b5e1e4a530b0bb5310fcb6d239abbec877110b/pillow-13.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:ff3fe9eb621fbb9db4c4be33ab946f6bac5f98ab50ca8d3260503653656c961b", size = 7272166, upload-time = "2026-10-16T22:15:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a3/be5a1236efd085b3984e4305da8f179ae9dab80c991d2ad78f2bd5c4c843/pillow-13.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:c3bc8ee0271387e0b1e3ad0ff893645990a656e79bf9c42f4f734a3642b11d51", size = 2698943, upload-time = "2026-10-16T22:16:00.637Z" }, + { url = "https://files.pythonhosted.org/packages/60/da/f6d644e631c5b370c5d0b899f3ccadd2780aab070b29a110527cf4e1fd2c/pillow-13.0.0-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:8619685cad8efc4c38c66d3b36aad854523297c43d748b3b6f0cc03c90ba3463", size = 5523188, upload-time = "2026-10-16T22:17:59.334Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b0/2594f202d743a35d4fe80682bfd96f277e5ca7aecb27cf64ef5bfc1d000b/pillow-13.0.0-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:7e91e97a929232cdb828889ee6ad0e48d7f40f4ec38818f4e4a8968b2b1af22e", size = 4806808, upload-time = "2026-10-16T22:18:01.188Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ed/d504165ef79af6fafe25c76726cf30f544cc236002f7ed054f1d51adc4ff/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93ff9c9c3663ce23aaa5d0b6b6abac55359e2a0c06c7e30f3771829af816be63", size = 5464358, upload-time = "2026-10-16T22:18:02.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/fe/a10aba5503ca13e9e009b8bd0a1a4ce5584bdce246245fd6f3a83701369c/pillow-13.0.0-pp312-pypy312_pp80-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e65dd6e75de67b2362a4fa3e16aa627595f65054dc4ec73d64680c84d592fa61", size = 6043514, upload-time = "2026-10-16T22:18:04.538Z" }, + { url = "https://files.pythonhosted.org/packages/c0/37/66b12dc476041fe523bca29c9d58fbf1ea92a4eaf73acfb9e993948f1191/pillow-13.0.0-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:567f20f1c75897d7663830da6ea8af719e03d712b1d9ee7f813319f9ece42e5a", size = 7366030, upload-time = "2026-10-16T22:18:06.638Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + +[[package]] +name = "pycparser" +version = "3.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.414" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/1b/244c7b710031ada80f27e579ec20d28a2285dfc318fed0339866b1047f12/pyright-1.1.414.tar.gz", hash = "sha256:523c0a97c60da6333234955c277730c9cf4f5bd6d5399e7b7d2b0fc5d3599524", size = 4154638, upload-time = "2026-09-10T12:26:53.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl", hash = "sha256:2a6b4b3298c9eec174c5ed83bd338de6eee82df2992f3e1930e6199d381be36f", size = 6225049, upload-time = "2026-09-10T12:26:51.427Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "qrcode" +version = "8.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/b2/7fc2931bfae0af02d5f53b174e9cf701adbb35f39d69c2af63d4a39f81a9/qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c", size = 43317, upload-time = "2025-05-01T15:44:24.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/b8/d2d6d731733f51684bbf76bf34dab3b70a9148e8f2cef2bb544fccec681a/qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f", size = 45986, upload-time = "2025-05-01T15:44:22.781Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/58/690cf56da80c8f2c8bf12042b6017beac2c34fe961f65420bcb3fb7fac38/ruff-0.17.2.tar.gz", hash = "sha256:84d632402d14c23b488f941db23712d6736a013225162062d34558c3f3c780eb", size = 5333400, upload-time = "2026-10-22T18:38:00.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/b8/8b6d69c57e8fe1cc6af1aa7493b72a1a5b5a8afe34694e302025441729d4/ruff-0.17.2-py3-none-linux_armv6l.whl", hash = "sha256:96a65e755d8dba094707fb23ab602111d9a31ec2835b3a6992a1914a8fcb211e", size = 10183857, upload-time = "2026-10-22T18:37:14.956Z" }, + { url = "https://files.pythonhosted.org/packages/e8/61/12cc2ff7b2fa90a268ce1e2162c23aaf76e601d22623f6a31b0eeaa764b0/ruff-0.17.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:39c5b55c960515cdb32be16e69efb5734c589c06a0eb38a22694524a336b889d", size = 10210904, upload-time = "2026-10-22T18:37:17.985Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7c/b8850a8e4ad29f64a827dd6c6902db4a9aa538fb2b9eef4e26084001a3bb/ruff-0.17.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:92a80d2f44584dabb0189b3a6c393e37e525e04942838482010575cb81945be7", size = 9408439, upload-time = "2026-10-22T18:37:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b6/6a64fae86ca5e5e29d5adc311e5481de726cedf9aee607435a16c31c442a/ruff-0.17.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b7d0906677d89bbf3cf73cde64b5bbe2ab35df3ae43e49ee2627301e91265bac", size = 10047078, upload-time = "2026-10-22T18:37:23.717Z" }, + { url = "https://files.pythonhosted.org/packages/10/19/23d376547715175aa1bd46df48df6aacc7c6439d9daf19cac15975180aa5/ruff-0.17.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7cfd783590edf2b77317882631900f6a13ea2e8eacefe6ebf2370d2715299160", size = 9990478, upload-time = "2026-10-22T18:37:26.584Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/34cda32cbe13adac5c6b291a307a5570d8ebd37c0c86339f0fe628e55632/ruff-0.17.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122258f6ecde0e0dd1bb60b24263c3edde4338caf723fca711cc729f9bfd414a", size = 10673624, upload-time = "2026-10-22T18:37:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/5d762a4dbc851d8bb47863cf3a28a61bf31c804d393b70d891fac899f5d1/ruff-0.17.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7f8fba429d6ac4128ab29cfb8fa6240d2fa671d04adf7f84d3b22fc7b6b6bea4", size = 11351797, upload-time = "2026-10-22T18:37:32.535Z" }, + { url = "https://files.pythonhosted.org/packages/3f/04/a0e92a81faf5c81c1c98d4374c8401734ccf2b8cca7d0170cb0fec08c6e2/ruff-0.17.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:583bd67bb9e7061dd09bc46a0a884bae2bbd214dbcd48e509813612619683869", size = 10949414, upload-time = "2026-10-22T18:37:35.501Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d8/59894cf630d2d86882457a2a6a0a1533410c5c805bfbac6f6eb4b5aef69d/ruff-0.17.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60f93fec7142dba5b8f487f94bf2f6231dae24dc11e4c48648347c8b0720004", size = 10528000, upload-time = "2026-10-22T18:37:37.88Z" }, + { url = "https://files.pythonhosted.org/packages/ab/95/d6e30c51e851d37e10762ad91fa1e76ddaad998589b5667962a3cfbc9edd/ruff-0.17.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c555a877f8f41447e318101cfefea9bf12dfa9f82eedf90c376e57e069f917d0", size = 10571779, upload-time = "2026-10-22T18:37:40.444Z" }, + { url = "https://files.pythonhosted.org/packages/f4/5a/a7e1d74fb464250137d3d4eca645d323536533b672004d04ee0e46e0caf3/ruff-0.17.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba405cbd5ec7bda1f555d5e52be1fd68d02608d4d95d24591bdac92cfacb579f", size = 10051707, upload-time = "2026-10-22T18:37:42.812Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f2/02fca29ca7999380f0350a7212fe755c11ab9c22d61327356cce8decdbc6/ruff-0.17.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8c21b4ab24485358f17291a4f8dc5399b834e963b8efedd920fe4fdb02081bb4", size = 9998227, upload-time = "2026-10-22T18:37:45.224Z" }, + { url = "https://files.pythonhosted.org/packages/d1/00/55aa604a093dfdc1526e570fae0bb7d37a2221c7b4728d5f080b2add16ba/ruff-0.17.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5875a16b0de70b43cb81eabea5ae5e8f5d1b2fd1b5e6ed07f80cfe5019d0bd5a", size = 10377692, upload-time = "2026-10-22T18:37:47.704Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5a/a7a640f47bb906ec8fa233ea84319d503e6dee02b59e3901bd312ebd1f9b/ruff-0.17.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2d086be452eed686aa51271ecf2cbe5037956517cc6ee39a17b4489ac12f8bbe", size = 10790587, upload-time = "2026-10-22T18:37:50.191Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/c3b74e1b4808438ea7f14bb914478025e2d51261f60774a54a0fa222ec46/ruff-0.17.2-py3-none-win32.whl", hash = "sha256:6bce7d1f5165701ae18f5df85c65afe78cddfa6c5fb4b9bdb0014d60388f3edd", size = 9870537, upload-time = "2026-10-22T18:37:52.486Z" }, + { url = "https://files.pythonhosted.org/packages/1c/9a/c7da96c2c96d20704cf9027f7a5121da272a8c05f1d1531ba949e3b3df04/ruff-0.17.2-py3-none-win_amd64.whl", hash = "sha256:1b59d447f197bef9ef48a22ba1a6016137753c6099e9bac9c36eb566e422424c", size = 10282826, upload-time = "2026-10-22T18:37:55.2Z" }, + { url = "https://files.pythonhosted.org/packages/81/60/a3604c79cb02777b6cb84da894104021986f06c4bc41375102cc95049a32/ruff-0.17.2-py3-none-win_arm64.whl", hash = "sha256:195ad918027f817c119310ca89a42d347887dfc28934aaa869325e9c5dc9d962", size = 9827036, upload-time = "2026-10-22T18:37:58.031Z" }, +] + +[[package]] +name = "starlette" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/eb/4e197bf26b023abafc5e9043ec8710d7de0fcb032d0969045236d72aa0b1/starlette-1.9.0.tar.gz", hash = "sha256:3d931de246ed870e35ce605974440816ef895e592f255d1396cfdf191d4c4318", size = 2732466, upload-time = "2026-10-18T15:50:33.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" }, + { url = "https://files.pythonhosted.org/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" }, + { url = "https://files.pythonhosted.org/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" }, + { url = "https://files.pythonhosted.org/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" }, + { url = "https://files.pythonhosted.org/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" }, + { url = "https://files.pythonhosted.org/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" }, + { url = "https://files.pythonhosted.org/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" }, + { url = "https://files.pythonhosted.org/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" }, + { url = "https://files.pythonhosted.org/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" }, + { url = "https://files.pythonhosted.org/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" }, + { url = "https://files.pythonhosted.org/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" }, +] + +[[package]] +name = "websockets" +version = "17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/4692be6d5bf739ed049b3813901c42091f8c054df89d5c089343823dea0d/websockets-17.3.tar.gz", hash = "sha256:1c7dd93a1c9f89297471493674b1fc1d9467cafd45374f2172213046bdb3a329", size = 188878, upload-time = "2026-10-19T16:58:05.813Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/29/902ffe19c0d35603102f31490dea342f083bc77d8f8bcdc7d8b16f5810f9/websockets-17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:41665c7ea01d3ae497a0c61fd22d3424230d18782a5fceb79f04db9ccb2042c9", size = 218250, upload-time = "2026-10-19T16:54:54.718Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/f56a2637b1aac263205b0e3cd7b2ff2e2f8b404b8118c991f4d122fd381f/websockets-17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8f7469408740cf06a79b01605f2ae0b2acc8839c03fcbbf05da49baee12d3302", size = 215927, upload-time = "2026-10-19T16:54:56.124Z" }, + { url = "https://files.pythonhosted.org/packages/22/7d/a27f25451c4cc62141992b51005688481210f3d3e63de3dc66f9e882ba59/websockets-17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d68da281ac208b6236ffa8beef23fe7c7c956d8bf09b421d447232fd94441b35", size = 216196, upload-time = "2026-10-19T16:54:57.538Z" }, + { url = "https://files.pythonhosted.org/packages/9f/77/40bbc1a5c016cf9f598dea21d1250a93245c995eafaec6401d0e4eb4e647/websockets-17.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:078d1d41f45ee34eed5a4f3bbe7c588caecbdd6e12dfca73e8c1b0e803773793", size = 225516, upload-time = "2026-10-19T16:54:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/3d/9c/91889c8c62fb7d16cd58e3074473a259303d6229bf0ce496aec87e66f3dc/websockets-17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7f391838e8adcf1f2fe71561d9e5a9f74ea7ffa5052eb2db329aac677383136", size = 225795, upload-time = "2026-10-19T16:55:00.438Z" }, + { url = "https://files.pythonhosted.org/packages/b8/29/2e5e2158e9920f806cd45f3cbc81cf5f3eef2b9e5a74b9ef049e2a3578e3/websockets-17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c15a165673bb3fb57facbf040edf5c0556d20ccd2e0ccc5f23ebb69fb715fdc1", size = 227034, upload-time = "2026-10-19T16:55:02.065Z" }, + { url = "https://files.pythonhosted.org/packages/b6/af/fbab5c4098b26026b877bfc139b1ea0d7c6eaf7a86b84544933b4c445f82/websockets-17.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28d1935c16782a1e3650c9baa79069b016fff739e292e4cb14bbb830d6930491", size = 229863, upload-time = "2026-10-19T16:55:03.415Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/d6e460a991607794072fe3fdfcb3221a0342af61586f5bd7ea40bddcfd21/websockets-17.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c534c9524a2acd0b97b7022074bec3784ac273c683cf9535087e437672ea8531", size = 227669, upload-time = "2026-10-19T16:55:04.763Z" }, + { url = "https://files.pythonhosted.org/packages/ec/8f/1c7da1cfb632e141e019cf2e0bbbd4e6dc8c198c8cc29544e4acf1a89f56/websockets-17.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b459d29c8b82a420a98fa0ce8e9cf787ce0062d98cc90604ef9574fb3cc26ff8", size = 226429, upload-time = "2026-10-19T16:55:06.112Z" }, + { url = "https://files.pythonhosted.org/packages/3e/63/c48ef8bbfbd982f2cfae1cfcf9e63f5bfbb8468fd37ca700507efc0b0007/websockets-17.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53209dffd91b0aefed9ea630fa6ad5c2944d6210ba3a8a3a8664dc1575289b14", size = 223653, upload-time = "2026-10-19T16:55:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/cb/37/7bedaad48ae8e89eaa4f3942c11be0c41de77ce2118a475f3ba05e9b5a0f/websockets-17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9662a9fe457820c60365b3209e03cbd8e4a817195d55de65a80fa680b4bea136", size = 226465, upload-time = "2026-10-19T16:55:08.791Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/ab9a8a0de4769ca1dce392021b6a922e21b4d3687fe9e1ab4db585a088f8/websockets-17.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5670a762c7ae388bf8aa04199fd436180a4d28318dca9c1eae7b38604676dbfd", size = 225071, upload-time = "2026-10-19T16:55:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/89/96/64d8460cec95f873c61c6d13a09b435915150640525ae56d5871400aec09/websockets-17.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ed59cb4ee258674a6ed9b8515e42079db02cb3be94f133fdd3f200183a40c49d", size = 225841, upload-time = "2026-10-19T16:55:11.519Z" }, + { url = "https://files.pythonhosted.org/packages/a6/98/2b83211ee3087db4ae66533782664c0e283cf4a08cbb14036018d0803e09/websockets-17.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0f78bb2e82fe88912c9d7b25c98e57a9b08655579af89f164fd4c0657a075d62", size = 226945, upload-time = "2026-10-19T16:55:12.85Z" }, + { url = "https://files.pythonhosted.org/packages/9f/fe/6b2a37d422d7f4dfc0c92c95a14649d9bd662243ed8b0774523ed2703604/websockets-17.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:71250e010eede33c1d638e2c7a98288520d67f782fd5eceecaeca3129233a09a", size = 224502, upload-time = "2026-10-19T16:55:14.48Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c5/364015c9311c89ea6085d54c70d654c5d27ba05014d44ef9fd9daa09462b/websockets-17.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:729989759feb81ff6493879a0d60a0bd7d2eee41001bbec0381a080318439382", size = 225483, upload-time = "2026-10-19T16:55:15.899Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b3/75d0cae8398816ed34a16a78252bfc8651f2f883cc5a568e1198395f15df/websockets-17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:90d8c4fa5ba293cf92de351fbc292cdaadb1a1d58d031a1a97ff9b8fb6250ec7", size = 225761, upload-time = "2026-10-19T16:55:17.28Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/9a7c4b2e87f47a2a6a066acdc2157e9b7cb6222a224b95a432f54ae786ce/websockets-17.3-cp312-cp312-win32.whl", hash = "sha256:0824e6ecee838cc28e6481062574c7fde49f9a2cedfacd36f0e91cb4b37fd6fe", size = 218722, upload-time = "2026-10-19T16:55:18.658Z" }, + { url = "https://files.pythonhosted.org/packages/f7/e8/e52953a8280a1e51cb9307218443f68c8f4dc4b9952db6021d5c12d03c0f/websockets-17.3-cp312-cp312-win_amd64.whl", hash = "sha256:6644eecd99c4e8cdf72e646e17b391bb70b054f44becde37dcdc48cbec7e1527", size = 219024, upload-time = "2026-10-19T16:55:19.987Z" }, + { url = "https://files.pythonhosted.org/packages/85/a4/005eb7c1f5c1a249c1231a6ed83a54ada0b6b8ffab33a608ca20e3034ea0/websockets-17.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a9133dbaedc6f1af358127275676171d9a1bb3e61f47ee62b2ff9acb78af3d0", size = 218950, upload-time = "2026-10-19T16:55:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1a/5582103e0362f86c4725051ded89e585ceaf750f26beace530b7e42c81c0/websockets-17.3-pp312-pypy312_pp80-macosx_10_15_x86_64.whl", hash = "sha256:0e65260a1b1d7b81651f47343d836de70b88b02358ae3658efa6382104ec37b1", size = 215872, upload-time = "2026-10-19T16:57:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/ef/a7/f69d65ed3ff9ae73d428a918300c23a992e4d50683090b8dc33cc3341d5a/websockets-17.3-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:94c02b0702656f083c098bdbd93bce22a7aa7a2b34ba56d2472efb67dab7cf58", size = 216136, upload-time = "2026-10-19T16:57:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/69/76/c64fda4ac18a3aeb32e66acec481011a766add480b6362fad80e2c3c1963/websockets-17.3-pp312-pypy312_pp80-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ade76f8c84ade325bbed96806357fffd353fcc29a5166d6dc6d39381f6d9f76c", size = 217080, upload-time = "2026-10-19T16:57:58.593Z" }, + { url = "https://files.pythonhosted.org/packages/6b/6d/085a3f0e6152bbe79917973f3d33cd008437de6c04675e2f15253b921f21/websockets-17.3-pp312-pypy312_pp80-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a215749c9a88ef2842e880215193e56ae60b856773c99702b078236bc6d4adb", size = 216966, upload-time = "2026-10-19T16:57:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b8/7eb904583ea2090b329f50487e6f8dc58eb558712b9f3c16f289f0be668e/websockets-17.3-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7b5d23ef144b32645d97bd99f382c4394c443395f45fe1576786838d26e308", size = 217828, upload-time = "2026-10-19T16:58:01.822Z" }, + { url = "https://files.pythonhosted.org/packages/79/36/f54b4a1cd9456ca0a65a76d77d181621433793aca633dfeed214748ff4f0/websockets-17.3-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:13eace1b315345ebac5b27821f5dcce24e5cf9976ab18f79e00a59bdd67e5d19", size = 306301, upload-time = "2026-10-19T16:58:03.161Z" }, + { url = "https://files.pythonhosted.org/packages/d7/b0/3f01fcb3406662f6deb1e3e72b34dd0979e993991c0b7435a9e12bc14f02/websockets-17.3-py3-none-any.whl", hash = "sha256:dc2b495a92b3971966bf102e73589f950db3ec1ca6713265355ce7771d97b199", size = 212375, upload-time = "2026-10-19T16:58:04.483Z" }, +] + +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +]