feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,395 @@
|
||||
"""登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/密码/token,tx 扫码/cookie,
|
||||
xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json。
|
||||
|
||||
成功返回 `ok(data)`({ code: 0, data });可预期失败抛 ApiError,其它异常按 502 上报
|
||||
(与原实现 `fail(reply, err)` 的 `err.status ?? 502` 一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.http.api_error import ApiError, upstream
|
||||
from app.http.envelope import ok
|
||||
from app.routes.refresh import refresh_all
|
||||
from app.sdk import auth as auth_mod
|
||||
from app.sdk.auth_store import clear_credentials, get_credentials, get_tx_auth, set_credentials
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
LOGIN_TAG = "登录"
|
||||
|
||||
|
||||
async def _call(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||||
"""调用平台登录实现并包成信封;ApiError 原样抛(保留状态码),其余转 502。"""
|
||||
try:
|
||||
return ok(await fn(*args, **kwargs))
|
||||
except ApiError:
|
||||
raise
|
||||
except NotImplementedError as err:
|
||||
raise ApiError(501, f"该登录方式尚未移植:{err}") from err
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
def _body(description: str) -> Any:
|
||||
# embed=True:与 TS 契约一致,所有登录接口都以 JSON 对象传参({"key": ...}),
|
||||
# 而不是把单个参数当成裸 JSON 值。多参数时 FastAPI 本就自动嵌套,行为不变。
|
||||
return Body(..., embed=True, description=description)
|
||||
|
||||
|
||||
# ---------- 网易云 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy/qr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="获取网易云扫码登录二维码",
|
||||
description="用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。",
|
||||
)
|
||||
async def wy_qr() -> Any:
|
||||
return await _call(auth_mod.wy.qr_key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy/qr/check",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="轮询网易云扫码状态",
|
||||
description="code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。",
|
||||
)
|
||||
async def wy_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
|
||||
return await _call(auth_mod.wy.qr_check, key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy/cellphone",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="网易云手机号登录",
|
||||
description="password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。",
|
||||
)
|
||||
async def wy_cellphone(
|
||||
phone: str = _body("手机号"),
|
||||
password: str | None = Body(None, description="明文密码"),
|
||||
captcha: str | None = Body(None, description="短信验证码"),
|
||||
countrycode: str = Body("86", description="国家码"),
|
||||
) -> Any:
|
||||
return await _call(auth_mod.wy.cellphone_login, phone, password, captcha, countrycode)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/wy",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定网易云 cookie",
|
||||
description="cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。"
|
||||
"绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。",
|
||||
)
|
||||
async def wy_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||||
return await _call(auth_mod.wy.login_by_cookie, cookie)
|
||||
|
||||
|
||||
@router.delete("/login/wy", tags=[LOGIN_TAG], summary="退出网易云登录")
|
||||
async def wy_logout() -> Any:
|
||||
clear_credentials("wy")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 酷狗 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/kg",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定酷狗 token",
|
||||
description="token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。"
|
||||
"推荐改用 /login/kg/qr 扫码或 /login/kg/cellphone 短信验证码登录,无需抓包。",
|
||||
)
|
||||
async def kg_bind(token: str = _body("酷狗 token"), userid: str | int = _body("酷狗 userid")) -> Any:
|
||||
return await _call(auth_mod.kg.login_by_token, token, userid)
|
||||
|
||||
|
||||
@router.post("/login/kg/qr", tags=[LOGIN_TAG], summary="获取酷狗扫码登录二维码")
|
||||
async def kg_qr() -> Any:
|
||||
return await _call(auth_mod.kg.qr_key)
|
||||
|
||||
|
||||
@router.post("/login/kg/qr/check", tags=[LOGIN_TAG], summary="轮询酷狗扫码状态")
|
||||
async def kg_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
|
||||
return await _call(auth_mod.kg.qr_check, key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/kg/captcha",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="发送酷狗短信验证码",
|
||||
description="向手机号发送登录验证码(有频控,勿频繁调用),随后用 /login/kg/cellphone 提交。",
|
||||
)
|
||||
async def kg_captcha(mobile: str = _body("手机号")) -> Any:
|
||||
return await _call(auth_mod.kg.send_captcha, mobile)
|
||||
|
||||
|
||||
@router.post("/login/kg/cellphone", tags=[LOGIN_TAG], summary="酷狗手机验证码登录")
|
||||
async def kg_cellphone(mobile: str = _body("手机号"), code: str = _body("短信验证码")) -> Any:
|
||||
return await _call(auth_mod.kg.cellphone_login, mobile, code)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/kg/password",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="酷狗账号密码登录",
|
||||
description="触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):"
|
||||
"滑块类需浏览器完成图形验证后调 /login/kg/verify 再重试;短信类(v_type=32)先提交短信码再带 code 重试。",
|
||||
)
|
||||
async def kg_password(
|
||||
username: str = _body("账号"),
|
||||
password: str = _body("密码"),
|
||||
code: str | None = Body(None, description="短信验证码(20028 短信验证通过后重试登录时携带)"),
|
||||
) -> Any:
|
||||
return await _call(auth_mod.kg.password_login, username, password, code)
|
||||
|
||||
|
||||
@router.post("/login/kg/verify", tags=[LOGIN_TAG], summary="提交酷狗风控验证结果")
|
||||
async def kg_verify(
|
||||
eventid: str = _body("needVerify 返回的 eventid"),
|
||||
v_type: int = _body("验证类型(needVerify 返回的 v_type)"),
|
||||
verifycode: str = _body("图形验证码凭证或短信验证码"),
|
||||
sid: str | None = Body(None, description="行为指纹 sid(省略时服务端生成)"),
|
||||
edt: str | None = Body(None, description="行为指纹 edt(省略时服务端生成)"),
|
||||
) -> Any:
|
||||
return await _call(auth_mod.kg.verify_user_info, eventid, v_type, verifycode, sid, edt)
|
||||
|
||||
|
||||
@router.delete("/login/kg", tags=[LOGIN_TAG], summary="退出酷狗登录")
|
||||
async def kg_logout() -> Any:
|
||||
clear_credentials("kg")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- QQ 音乐 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/tx/qr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="获取 QQ 音乐扫码登录二维码",
|
||||
description="返回 qrimg(data URL)可直接展示,qrsig 需原样回传。",
|
||||
)
|
||||
async def tx_qr() -> Any:
|
||||
return await _call(auth_mod.tx.qr_show)
|
||||
|
||||
|
||||
@router.post("/login/tx/qr/check", tags=[LOGIN_TAG], summary="轮询 QQ 音乐扫码状态")
|
||||
async def tx_qr_check(qrsig: str = _body("qrShow 返回的 qrsig")) -> Any:
|
||||
return await _call(auth_mod.tx.qr_check, qrsig)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/tx",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定 QQ 音乐 cookie",
|
||||
description="cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),"
|
||||
"用于解锁 tx 取流的 320k/无损音质。",
|
||||
)
|
||||
async def tx_bind(
|
||||
cookie: str = _body("浏览器 cookie"),
|
||||
openId: str | None = Body(None, description="App 抓包的 openId(用于自动续期)"),
|
||||
accessToken: str | None = Body(None, description="App 抓包的 accessToken"),
|
||||
refreshToken: str | None = Body(None, description="App 抓包的 refreshToken"),
|
||||
refreshKey: str | None = Body(None, description="App 抓包的 refreshKey"),
|
||||
) -> Any:
|
||||
import re
|
||||
|
||||
match = re.search(r"uin=(\d+)", cookie)
|
||||
if not match or not re.search(r"qm_keyst=\S+", cookie):
|
||||
raise ApiError(422, "cookie 需包含 uin 与 qm_keyst 字段")
|
||||
uin = match.group(1)
|
||||
set_credentials(
|
||||
"tx",
|
||||
{
|
||||
"cookie": cookie,
|
||||
"uin": uin,
|
||||
"openId": openId,
|
||||
"accessToken": accessToken,
|
||||
"refreshToken": refreshToken,
|
||||
"refreshKey": refreshKey,
|
||||
},
|
||||
)
|
||||
return ok({"logged": True, "uin": uin})
|
||||
|
||||
|
||||
@router.delete("/login/tx", tags=[LOGIN_TAG], summary="退出 QQ 音乐登录")
|
||||
async def tx_logout() -> Any:
|
||||
clear_credentials("tx")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 喜马拉雅 / 猫耳 / 懒人听书 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/xm",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定喜马拉雅 cookie",
|
||||
description="cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段)。",
|
||||
)
|
||||
async def xm_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||||
import re
|
||||
|
||||
if not re.search(r"1&_token=\S+", cookie):
|
||||
raise ApiError(422, "cookie 需包含 1&_token 字段")
|
||||
set_credentials("xm", {"cookie": cookie})
|
||||
return ok({"logged": True})
|
||||
|
||||
|
||||
@router.delete("/login/xm", tags=[LOGIN_TAG], summary="退出喜马拉雅登录")
|
||||
async def xm_logout() -> Any:
|
||||
clear_credentials("xm")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/me",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定猫耳FM token",
|
||||
description="cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段)。",
|
||||
)
|
||||
async def me_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||||
import re
|
||||
|
||||
if not re.search(r"(?:^|;\s*)token=\S+", cookie):
|
||||
raise ApiError(422, "cookie 需包含 token 字段")
|
||||
set_credentials("me", {"cookie": cookie})
|
||||
return ok({"logged": True})
|
||||
|
||||
|
||||
@router.delete("/login/me", tags=[LOGIN_TAG], summary="退出猫耳FM登录")
|
||||
async def me_logout() -> Any:
|
||||
clear_credentials("me")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/lr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="懒人听书账号登录",
|
||||
description="m.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化,"
|
||||
"此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。",
|
||||
)
|
||||
async def lr_login(
|
||||
account: str = _body("账号(手机号)"), password: str = _body("密码(明文传入,服务端自行编码)")
|
||||
) -> Any:
|
||||
return await _call(auth_mod.lr.login, account, password)
|
||||
|
||||
|
||||
@router.delete("/login/lr", tags=[LOGIN_TAG], summary="退出懒人听书登录")
|
||||
async def lr_logout() -> Any:
|
||||
clear_credentials("lr")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 哔哩哔哩 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/bili/qr",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="获取哔哩哔哩扫码登录二维码",
|
||||
description="用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL。",
|
||||
)
|
||||
async def bili_qr() -> Any:
|
||||
return await _call(auth_mod.bili.create_qr_login)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/bili/qr/check",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="轮询哔哩哔哩扫码状态",
|
||||
description="status:waiting / scanned / success(自动保存凭据)/ expired / failed。"
|
||||
"code 为映射后的状态码:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(与其它平台一致)。",
|
||||
)
|
||||
async def bili_qr_check(key: str = _body("createQRLogin 返回的 key")) -> Any:
|
||||
return await _call(auth_mod.bili.check_qr_login, key)
|
||||
|
||||
|
||||
@router.delete("/login/bili", tags=[LOGIN_TAG], summary="退出哔哩哔哩登录")
|
||||
async def bili_logout() -> Any:
|
||||
clear_credentials("bili")
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
# ---------- 通用 ----------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/refresh",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="手动触发登录态续期",
|
||||
description="对已登录的 kg/tx 执行 token/cookie 续期(与自动任务同一逻辑)。"
|
||||
"未登录的平台跳过;失败的平台在对应字段返回 error,不影响其他平台。",
|
||||
)
|
||||
async def login_refresh() -> Any:
|
||||
return ok(await refresh_all())
|
||||
|
||||
|
||||
def _stored_status(package: str) -> dict[str, Any]:
|
||||
"""按已存凭据判断登录态(xm/me/bd/joox/apple/5sing 用)。"""
|
||||
cred = get_credentials(package)
|
||||
if not cred:
|
||||
return {"logged": False}
|
||||
return {"logged": True, "updatedAt": cred.get("updatedAt")}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/login/status",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="查看登录状态",
|
||||
description="wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据"
|
||||
"(含 VIP 等级与最近更新时间),xm/me/lr/bd/joox/apple/5sing 为已存凭据。",
|
||||
)
|
||||
async def login_status() -> Any:
|
||||
|
||||
async def safe_wy() -> dict[str, Any]:
|
||||
try:
|
||||
result = await auth_mod.wy.status()
|
||||
return {"logged": True, **result} if result else {"logged": False}
|
||||
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
|
||||
return {"logged": False}
|
||||
|
||||
async def safe_kg() -> dict[str, Any]:
|
||||
cred = get_credentials("kg")
|
||||
if not cred or not cred.get("token"):
|
||||
return {"logged": False}
|
||||
try:
|
||||
result = await auth_mod.kg.status()
|
||||
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
|
||||
result = None
|
||||
payload = {**(result or {}), "updatedAt": cred.get("updatedAt")}
|
||||
return {"logged": True, **payload}
|
||||
|
||||
wy_result = await safe_wy()
|
||||
tx = get_tx_auth()
|
||||
tx_cred = get_credentials("tx") or {}
|
||||
return ok(
|
||||
{
|
||||
"wy": wy_result,
|
||||
"kg": await safe_kg(),
|
||||
"tx": (
|
||||
{
|
||||
"logged": True,
|
||||
"uin": tx["uin"],
|
||||
"vipType": tx_cred.get("vipType", None),
|
||||
"updatedAt": tx_cred.get("updatedAt"),
|
||||
}
|
||||
if tx
|
||||
else {"logged": False}
|
||||
),
|
||||
"xm": _stored_status("xm"),
|
||||
"me": _stored_status("me"),
|
||||
"lr": _stored_status("lr"),
|
||||
"bili": _stored_status("bili"),
|
||||
"bd": _stored_status("bd"),
|
||||
"joox": _stored_status("joox"),
|
||||
"apple": _stored_status("apple"),
|
||||
"5sing": _stored_status("5sing"),
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,869 @@
|
||||
"""业务路由 —— 搜索、取流、歌词、封面、榜单、歌单、歌手专辑、链接解析。
|
||||
|
||||
处理函数返回 `ok(data)`,由 main.Envelope 统一包成 { code: 0, data };
|
||||
抛出 ApiError 由全局异常处理器转成对应 HTTP 状态码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.http.api_error import not_found, upstream
|
||||
from app.http.envelope import ok
|
||||
from app.http.mg_stream import (
|
||||
Z3DStreamDecoder,
|
||||
migu_stream_content_type,
|
||||
register_migu_stream,
|
||||
stream_decrypted,
|
||||
take_migu_stream,
|
||||
)
|
||||
from app.http.stream_sign import sign_stream
|
||||
from app.sdk import SOURCE_MODULES, SUPPORTED_SOURCES, find_music
|
||||
from app.sdk.types import cap as get_cap
|
||||
from fastapi import APIRouter, Body, Path, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
QUALITIES = ["64k", "128k", "320k", "flac", "flac24bit"]
|
||||
|
||||
# ---- 常用 OpenAPI 响应片段 ----
|
||||
ENVELOPE_SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "integer", "description": "0 表示成功", "example": 0},
|
||||
"data": {"description": "业务数据"},
|
||||
},
|
||||
"required": ["code", "data"],
|
||||
}
|
||||
|
||||
|
||||
def envelope_ref(name: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "integer", "description": "0 表示成功", "example": 0},
|
||||
"data": {"$ref": f"#/components/schemas/{name}"},
|
||||
},
|
||||
"required": ["code", "data"],
|
||||
}
|
||||
|
||||
|
||||
ERROR_ENVELOPE = {"$ref": "#/components/schemas/ErrorEnvelope"}
|
||||
|
||||
|
||||
def responses_for(
|
||||
data_ref: str, *, not_found: bool = True, upstream_doc: bool = True
|
||||
) -> dict[int | str, dict[str, Any]]:
|
||||
docs: dict[int | str, dict[str, Any]] = {
|
||||
"200": {"description": "成功", "content": {"application/json": {"schema": envelope_ref(data_ref)}}},
|
||||
"422": {"description": "参数校验失败", "content": {"application/json": {"schema": ERROR_ENVELOPE}}},
|
||||
}
|
||||
if not_found:
|
||||
docs["404"] = {"description": "接口或平台不存在", "content": {"application/json": {"schema": ERROR_ENVELOPE}}}
|
||||
if upstream_doc:
|
||||
docs["502"] = {"description": "上游平台失败", "content": {"application/json": {"schema": ERROR_ENVELOPE}}}
|
||||
return docs
|
||||
|
||||
|
||||
def get_source(source: str, capability: str | None = None) -> Any:
|
||||
"""取平台模块(按能力位校验),不存在或无该能力 → 404。"""
|
||||
module = SOURCE_MODULES.get(source)
|
||||
if module is None or get_cap(module, capability) is None:
|
||||
raise not_found(f"不支持的平台: {source}")
|
||||
return module
|
||||
|
||||
|
||||
def platform_path(source: str, capability: str | None = None, description: str = "平台 id(见 /sources)") -> Any:
|
||||
return Path(..., description=f"{description}{f',需支持 {capability} 能力' if capability else ''}", example="kw")
|
||||
|
||||
|
||||
# ---------- 搜索 ----------
|
||||
|
||||
|
||||
async def _search_handler(source: str, keyword: str, page: int, singer: str | None, limit: int) -> Any:
|
||||
word = f"{keyword} {singer or ''}".strip()
|
||||
if source != "all":
|
||||
get_source(source, "music_search")
|
||||
result = await search_platform(source, word, page, limit)
|
||||
return {"source": source, "list": result, "page": page, "limit": limit}
|
||||
|
||||
# 聚合:排除有声平台,失败的平台跳过
|
||||
import asyncio
|
||||
|
||||
targets = [
|
||||
item["id"]
|
||||
for item in SUPPORTED_SOURCES
|
||||
if getattr(item["module"], "music_search", None) is not None and not item.get("no_aggregate")
|
||||
]
|
||||
|
||||
async def one(platform: str):
|
||||
try:
|
||||
return await search_platform(platform, word, page, limit)
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
|
||||
lists = await asyncio.gather(*(one(platform) for platform in targets))
|
||||
return {"source": "all", "list": [song for chunk in lists for song in chunk], "page": page, "limit": limit}
|
||||
|
||||
|
||||
async def search_platform(platform: str, word: str, page: int, limit: int) -> list[dict[str, Any]]:
|
||||
try:
|
||||
result = await SOURCE_MODULES[platform].music_search.search(word, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
return result.get("list") or []
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/{source}/{keyword}",
|
||||
summary="搜索歌曲(第 1 页)",
|
||||
description="source 为平台 id 或 all(聚合,排除 xm/me/qt/lr 有声平台,失败的平台跳过)。"
|
||||
"path 直接跟搜索内容,第 1 页可省略页码段;歌名含空格/斜杠时请拆分——歌手走 singer 查询参数。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("SearchResult", not_found=False),
|
||||
)
|
||||
async def search_first(
|
||||
source: str = platform_path("kw", "search"),
|
||||
keyword: str = Path(..., description="搜索关键词(URL 编码的中文可直接使用)", example="稻香"),
|
||||
singer: str | None = Query(None, description="歌手名(与关键词拼接搜索)", example="周杰伦"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return ok(await _search_handler(source, keyword, 1, singer, limit))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/search/{source}/{keyword}/{page}",
|
||||
summary="搜索歌曲(指定页)",
|
||||
description="同 /search/:source/:keyword,页码作为 path 段。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("SearchResult", not_found=False),
|
||||
)
|
||||
async def search_page(
|
||||
source: str = platform_path("kw", "search"),
|
||||
keyword: str = Path(..., description="搜索关键词", example="稻香"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
singer: str | None = Query(None, description="歌手名"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return ok(await _search_handler(source, keyword, page, singer, limit))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/find/{source}/{keyword}",
|
||||
summary="精确匹配歌曲",
|
||||
description="按歌手、歌名、专辑、时长综合排序的精确匹配。source 为平台 id(只在单平台内匹配)或 all"
|
||||
"(跨平台聚合,可用 excludeSource 跳过某平台;聚合自动排除 xm/me/qt/lr 有声平台)。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("FindResult", not_found=False),
|
||||
)
|
||||
async def find(
|
||||
source: str = Path(..., description="平台 id 或 all", example="all"),
|
||||
keyword: str = Path(..., description="歌名", example="稻香"),
|
||||
singer: str | None = Query(None, description="歌手名", example="周杰伦"),
|
||||
albumName: str | None = Query(None, description="专辑名", example="魔杰座"),
|
||||
interval: str | None = Query(None, description="时长(mm:ss),用于时长校验", example="03:43"),
|
||||
excludeSource: str | None = Query(None, description="source=all 时跳过该平台", example="kw"),
|
||||
) -> Any:
|
||||
kwargs: dict[str, Any] = {
|
||||
"name": keyword,
|
||||
"singer": singer,
|
||||
"album_name": albumName,
|
||||
"interval": interval,
|
||||
}
|
||||
if source != "all":
|
||||
kwargs["only"] = source
|
||||
elif excludeSource:
|
||||
kwargs["source"] = excludeSource
|
||||
try:
|
||||
return ok({"list": await find_music(**kwargs)})
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
# ---------- 歌曲资源 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/url/{source}/{id}",
|
||||
summary="获取播放地址",
|
||||
description="id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,xm/me 用声音 id,qt 为 <channelId>:<programId>"
|
||||
"(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。"
|
||||
"取流按「SDK 官方直连 → 酷我兜底」顺序尝试,酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),"
|
||||
"kg 可由 hash 反查歌曲信息,其它平台需要兜底时传 name/singer/interval。"
|
||||
"响应中的 via 字段标记实际命中通道(仅兜底时出现)。",
|
||||
tags=["歌曲资源"],
|
||||
responses=responses_for("MusicUrlResult"),
|
||||
)
|
||||
async def music_url(
|
||||
request: Request,
|
||||
source: str = platform_path("kw", "url"),
|
||||
id: str = Path(..., description="平台歌曲 id", example="28423286"),
|
||||
quality: str = Query("128k", description="音质"),
|
||||
albumId: str | None = Query(None, description="专辑 id"),
|
||||
name: str | None = Query(None, description="歌名(酷我兜底匹配用)"),
|
||||
singer: str | None = Query(None, description="歌手名(酷我兜底匹配用)"),
|
||||
interval: str | None = Query(None, description="时长 mm:ss(酷我兜底匹配用)", example="04:17"),
|
||||
) -> Any:
|
||||
if quality not in QUALITIES:
|
||||
from app.http.api_error import ApiError
|
||||
|
||||
raise ApiError(422, f"quality: 不支持的音质 {quality}(可选:{', '.join(QUALITIES)})")
|
||||
|
||||
module = get_source(source, "get_music_url")
|
||||
song_info = {"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or "", "_types": {}}
|
||||
|
||||
def finish(result: dict[str, Any], via: str) -> dict[str, Any]:
|
||||
if not result or not result.get("url"):
|
||||
raise RuntimeError("获取播放地址失败")
|
||||
raw_url = str(result["url"])
|
||||
if result.get("encrypted") and result.get("fileKey"):
|
||||
raw_url = f"/stream/mg/{register_migu_stream(raw_url, str(result['fileKey']))}"
|
||||
# 相对地址按请求的协议与 host 补全(兼容本机/局域网播放器)
|
||||
if raw_url.startswith("/"):
|
||||
suffix = ""
|
||||
auth_key = getattr(request.app.state, "auth_key", "")
|
||||
if auth_key:
|
||||
path = raw_url.split("?")[0]
|
||||
suffix = f"{'&' if '?' in raw_url else '?'}{sign_stream(auth_key, path)}"
|
||||
raw_url = f"{request.url.scheme}://{request.headers.get('host', '')}{raw_url}{suffix}"
|
||||
payload: dict[str, Any] = {"type": result.get("type") or quality, "url": raw_url}
|
||||
if via != source:
|
||||
payload["via"] = via
|
||||
return payload
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
return ok(finish(await module.get_music_url(song_info, quality), source))
|
||||
except Exception as err: # noqa: BLE001
|
||||
errors.append(f"{source}: {err}")
|
||||
|
||||
# 酷我兜底:匿名可拿真无损的通道,按歌名/歌手/时长匹配同一首歌后取流
|
||||
if source != "kw":
|
||||
try:
|
||||
meta: dict[str, Any] | None = {"name": name, "singer": singer, "interval": interval} if name else None
|
||||
if not meta and source == "kg":
|
||||
# kg 可用 hash 反查歌曲信息,无需调用方额外传参
|
||||
kg_module = SOURCE_MODULES.get("kg")
|
||||
lookup = getattr(kg_module, "get_music_info", None)
|
||||
info = await lookup({"hash": id, "songmid": id}) if lookup else None
|
||||
if info and info.get("name"):
|
||||
meta = {
|
||||
"name": str(info["name"]),
|
||||
"singer": str(info["singer"]) if info.get("singer") else None,
|
||||
"interval": str(info["interval"]) if info.get("interval") else None,
|
||||
}
|
||||
if meta and meta.get("name"):
|
||||
best_list = await find_music(
|
||||
name=str(meta["name"]),
|
||||
singer=meta.get("singer"),
|
||||
interval=meta.get("interval"),
|
||||
only="kw",
|
||||
)
|
||||
best = best_list[0] if best_list else None
|
||||
if best:
|
||||
kw_module = SOURCE_MODULES["kw"]
|
||||
res = await kw_module.get_music_url(
|
||||
{"songmid": best["songmid"], "hash": best["songmid"], "_types": {}}, quality
|
||||
)
|
||||
return ok(finish(res, "kw"))
|
||||
errors.append("kw 兜底: 未匹配到同名歌曲")
|
||||
else:
|
||||
errors.append("kw 兜底: 缺少歌名信息(传 name/singer/interval 查询参数可启用)")
|
||||
except Exception as err: # noqa: BLE001
|
||||
errors.append(f"kw 兜底: {err}")
|
||||
|
||||
raise upstream(f"取流失败({';'.join(errors)})")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stream/{source}/{id}",
|
||||
summary="加密流解密代理(mg)",
|
||||
description="取流响应中返回的相对地址(/url/mg 命中加密流时的地址),服务端下载上游密流并在本地解密后返回明文音频;"
|
||||
"不接受客户端自行构造。id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。",
|
||||
tags=["歌曲资源"],
|
||||
response_class=StreamingResponse,
|
||||
)
|
||||
async def stream(source: str, id: str) -> Any:
|
||||
if source != "mg":
|
||||
raise not_found(f"不支持的加密流平台: {source}")
|
||||
ticket = take_migu_stream(str(id))
|
||||
if not ticket:
|
||||
raise not_found("解密票据不存在或已过期(请重新调用 /url/mg 取流)")
|
||||
return _stream_response(ticket.url, Z3DStreamDecoder(ticket.file_key))
|
||||
|
||||
|
||||
def _stream_response(url: str, decoder: Z3DStreamDecoder) -> StreamingResponse:
|
||||
"""异步队列桥接:下载协程生产解密块,StreamingResponse 消费块。
|
||||
|
||||
解密按块增量进行(跨块保持全局偏移),整段音频可达上百 MB,不能读进内存。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=32)
|
||||
|
||||
async def produce() -> None:
|
||||
try:
|
||||
await stream_decrypted(url, decoder.feed, queue.put_nowait)
|
||||
except Exception as err: # noqa: BLE001 - 响应头已发出,只能中断连接`r`n # 日志留痕,只能中断连接;把错误留在服务端日志
|
||||
print(f"[stream] 上游读取中断: {err!r}")
|
||||
finally:
|
||||
queue.put_nowait(None)
|
||||
|
||||
async def body():
|
||||
task = asyncio.create_task(produce())
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
task.cancel()
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
media_type=migu_stream_content_type(url),
|
||||
headers={"Accept-Ranges": "none"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/lyric/{source}/{id}",
|
||||
summary="获取歌词",
|
||||
tags=["歌曲资源"],
|
||||
responses=responses_for("LyricInfo"),
|
||||
)
|
||||
async def lyric(
|
||||
source: str = platform_path("kw", "lyric"),
|
||||
id: str = Path(..., description="平台歌曲 id"),
|
||||
name: str | None = Query(None, description="歌名(部分平台搜索歌词时需要)"),
|
||||
interval: str | None = Query(None, description="时长(mm:ss),部分平台搜索歌词时需要"),
|
||||
) -> Any:
|
||||
module = get_source(source, "get_lyric")
|
||||
try:
|
||||
result = await module.get_lyric(
|
||||
{"songmid": id, "hash": id, "copyrightId": id, "name": name or "", "interval": interval or ""}
|
||||
)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
return ok(result or {"lyric": "", "tlyric": ""})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pic/{source}/{id}",
|
||||
summary="获取封面",
|
||||
tags=["歌曲资源"],
|
||||
responses=responses_for("PicResult"),
|
||||
)
|
||||
async def pic(
|
||||
source: str = platform_path("kw", "pic"),
|
||||
id: str = Path(..., description="平台歌曲 id"),
|
||||
albumId: str | None = Query(None, description="专辑 id(kg 平台需要)"),
|
||||
) -> Any:
|
||||
module = get_source(source, "get_pic")
|
||||
try:
|
||||
url = await module.get_pic({"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or ""})
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
return ok({"url": url})
|
||||
|
||||
|
||||
# ---------- 榜单 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{source}",
|
||||
summary="获取榜单列表",
|
||||
tags=["榜单"],
|
||||
responses=responses_for("BoardListResult"),
|
||||
)
|
||||
async def boards(source: str = platform_path("kw", "boards")) -> Any:
|
||||
module = get_source(source, "leaderboard")
|
||||
try:
|
||||
return ok(await module.leaderboard.get_boards())
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
async def _board_handler(source: str, board_id: str, page: int) -> Any:
|
||||
module = get_source(source, "leaderboard")
|
||||
# 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化
|
||||
bare = board_id[len(f"{source}__") :] if board_id.startswith(f"{source}__") else board_id
|
||||
try:
|
||||
return ok(await module.leaderboard.get_list(bare, page))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{source}/{boardId}",
|
||||
summary="获取榜单详情",
|
||||
tags=["榜单"],
|
||||
responses=responses_for("BoardDetailResult"),
|
||||
)
|
||||
async def board_first(
|
||||
source: str = platform_path("kw", "boards"),
|
||||
boardId: str = Path(
|
||||
..., description="榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)", example="kw__93"
|
||||
),
|
||||
) -> Any:
|
||||
return await _board_handler(source, boardId, 1)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/boards/{source}/{boardId}/{page}",
|
||||
summary="获取榜单详情(指定页)",
|
||||
tags=["榜单"],
|
||||
responses=responses_for("BoardDetailResult"),
|
||||
)
|
||||
async def board_page(
|
||||
source: str = platform_path("kw", "boards"),
|
||||
boardId: str = Path(..., description="榜单 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
) -> Any:
|
||||
return await _board_handler(source, boardId, page)
|
||||
|
||||
|
||||
# ---------- 热搜 & 歌单列表 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/hot-search/{source}",
|
||||
summary="获取热搜词",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("HotSearchResult"),
|
||||
)
|
||||
async def hot_search(source: str = platform_path("kw", "hot_search")) -> Any:
|
||||
module = get_source(source, "hot_search")
|
||||
try:
|
||||
return ok(await module.hot_search.get_list())
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
async def _song_list_handler(source: str, page: int, sort_id: Any, tag_id: Any) -> Any:
|
||||
module = get_source(source, "song_list")
|
||||
sort_default = module.song_list.sort_list[0]["id"] if getattr(module.song_list, "sort_list", None) else None
|
||||
try:
|
||||
return ok(await module.song_list.get_list(sort_id if sort_id is not None else sort_default, tag_id, page))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/song-list/{source}",
|
||||
summary="获取歌单列表",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("SongListResult"),
|
||||
)
|
||||
async def song_list_first(
|
||||
source: str = platform_path("kw", "song_list"),
|
||||
sortId: str | None = Query(None, description="排序 id(平台相关;省略时用平台默认)"),
|
||||
tagId: str | None = Query(None, description="标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)"),
|
||||
) -> Any:
|
||||
return await _song_list_handler(source, 1, sortId, tagId)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/song-list/{source}/{page}",
|
||||
summary="获取歌单列表(指定页)",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("SongListResult"),
|
||||
)
|
||||
async def song_list_page(
|
||||
source: str = platform_path("kw", "song_list"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
sortId: str | None = Query(None, description="排序 id"),
|
||||
tagId: str | None = Query(None, description="标签 id"),
|
||||
) -> Any:
|
||||
return await _song_list_handler(source, page, sortId, tagId)
|
||||
|
||||
|
||||
# ---------- 歌手 & 专辑 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}",
|
||||
summary="获取歌手信息",
|
||||
description="支持 kg/tx/wy(见 /sources 的 singer 能力位)。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerInfoResult"),
|
||||
)
|
||||
async def singer_info(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
) -> Any:
|
||||
module = get_source(source, "singer")
|
||||
try:
|
||||
return ok(await module.singer.get_info(id))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/songs",
|
||||
summary="获取歌手歌曲列表",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerSongListResult"),
|
||||
)
|
||||
async def singer_songs_first(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_songs(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/songs/{page}",
|
||||
summary="获取歌手歌曲列表(指定页)",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerSongListResult"),
|
||||
)
|
||||
async def singer_songs_page(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_songs(source, id, page, limit)
|
||||
|
||||
|
||||
async def _singer_songs(source: str, singer_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "singer")
|
||||
try:
|
||||
return ok(await module.singer.get_song_list(singer_id, page, limit))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/albums",
|
||||
summary="获取歌手专辑列表",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerAlbumListResult"),
|
||||
)
|
||||
async def singer_albums_first(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_albums(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/singer/{source}/{id}/albums/{page}",
|
||||
summary="获取歌手专辑列表(指定页)",
|
||||
description="支持 kg/tx/wy。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("SingerAlbumListResult"),
|
||||
)
|
||||
async def singer_albums_page(
|
||||
source: str = platform_path("kg", "singer"),
|
||||
id: str = Path(..., description="平台歌手 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _singer_albums(source, id, page, limit)
|
||||
|
||||
|
||||
async def _singer_albums(source: str, singer_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "singer")
|
||||
try:
|
||||
return ok(await module.singer.get_album_list(singer_id, page, limit))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
async def _album_detail(source: str, album_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "album")
|
||||
album = module.album
|
||||
# 各平台适配器签名不同:kg 为 get_album_detail(id, page, limit),
|
||||
# kw 为 get_album_list_detail(id, page)——第三参数是 retryNum,不能透传 limit
|
||||
try:
|
||||
if hasattr(album, "get_album_detail"):
|
||||
return ok(await album.get_album_detail(album_id, page, limit))
|
||||
return ok(await album.get_album_list_detail(album_id, page))
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/album/{source}/{id}",
|
||||
summary="获取专辑详情与曲目列表",
|
||||
description="支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple(见 /sources 的 album 能力位)。"
|
||||
"kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("AlbumDetailResult"),
|
||||
)
|
||||
async def album_first(
|
||||
source: str = platform_path("kg", "album"),
|
||||
id: str = Path(..., description="平台专辑 id(搜索结果中的 albumId)", example="123456"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _album_detail(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/album/{source}/{id}/{page}",
|
||||
summary="获取专辑详情与曲目列表(指定页)",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("AlbumDetailResult"),
|
||||
)
|
||||
async def album_page(
|
||||
source: str = platform_path("kg", "album"),
|
||||
id: str = Path(..., description="平台专辑 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _album_detail(source, id, page, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/album/{source}/search/{keyword}",
|
||||
summary="搜索专辑",
|
||||
description="支持 kg/kw/tx/wy/mg/bd/joox/apple(见 /sources 的 albumSearch 能力位)。",
|
||||
tags=["歌手专辑"],
|
||||
responses=responses_for("AlbumDetailResult", not_found=False),
|
||||
)
|
||||
async def album_search(
|
||||
source: str = platform_path("kg", "album.search_album"),
|
||||
keyword: str = Path(..., description="专辑名关键词", example="魔杰座"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
module = get_source(source, "album.search_album")
|
||||
try:
|
||||
res = await module.album.search_album(keyword, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
if isinstance(res, list):
|
||||
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
|
||||
return ok({"source": source, **res})
|
||||
|
||||
|
||||
# ---------- 歌单 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/search/{keyword}",
|
||||
summary="搜索歌单",
|
||||
description="支持 kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。"
|
||||
"部分平台该能力受限(如 bd 歌单搜索接口已下线、bili 需登录),失败时返回 502。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistSearchResult"),
|
||||
)
|
||||
async def playlist_search(
|
||||
source: str = platform_path("kw", "playlist.search_playlist"),
|
||||
keyword: str = Path(..., description="歌单关键词", example="华语"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
module = get_source(source, "playlist.search_playlist")
|
||||
try:
|
||||
res = await module.playlist.search_playlist(keyword, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
if isinstance(res, list):
|
||||
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
|
||||
return ok({"source": source, **res})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/categories",
|
||||
summary="获取歌单分类",
|
||||
description="支持 kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistCategoriesResult"),
|
||||
)
|
||||
async def playlist_categories(source: str = platform_path("kw", "playlist.get_categories")) -> Any:
|
||||
module = get_source(source, "playlist.get_categories")
|
||||
try:
|
||||
res = await module.playlist.get_categories()
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
# 歌单分类返回形状不一({list}/裸数组),统一为 { source, list }
|
||||
items = res if isinstance(res, list) else (res or {}).get("list") or []
|
||||
return ok({"source": source, "list": items})
|
||||
|
||||
|
||||
async def _category_playlists(source: str, category_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "playlist")
|
||||
if not hasattr(module.playlist, "get_category_playlists"):
|
||||
raise not_found(f"平台 {source} 不支持分类歌单")
|
||||
try:
|
||||
res = await module.playlist.get_category_playlists(category_id, page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
if isinstance(res, list):
|
||||
return ok({"source": source, "list": res, "page": page, "limit": limit, "total": len(res)})
|
||||
return ok({"source": source, **res})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/categories/{categoryId}",
|
||||
summary="获取分类下的歌单",
|
||||
description="categoryId 为 /playlist/:source/categories 返回的分类 id(部分平台 id 即分类名)。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistSearchResult"),
|
||||
)
|
||||
async def playlist_category_items(
|
||||
source: str = platform_path("kw", "playlist.get_categories"),
|
||||
categoryId: str = Path(..., description="分类 id(分类列表返回)", example="华语"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _category_playlists(source, categoryId, page, limit)
|
||||
|
||||
|
||||
async def _playlist_detail(source: str, playlist_id: str, page: int, limit: int) -> Any:
|
||||
module = get_source(source, "playlist")
|
||||
try:
|
||||
return ok(await module.playlist.get_playlist_detail(playlist_id, page, limit))
|
||||
except Exception as err: # noqa: BLE001
|
||||
# 部分平台详情签名只有 id:退化为取全量后本地切片
|
||||
try:
|
||||
songs = await module.playlist.get_playlist_songs(playlist_id)
|
||||
if not isinstance(songs, list):
|
||||
raise err
|
||||
start = (page - 1) * limit
|
||||
return ok(
|
||||
{
|
||||
"source": source,
|
||||
"info": {"name": playlist_id, "img": "", "desc": "", "author": ""},
|
||||
"list": songs[start : start + limit],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": len(songs),
|
||||
}
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/{id}",
|
||||
summary="获取歌单详情与曲目列表",
|
||||
description="id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:<id>:<mid>:<bvid>、"
|
||||
"joox/bd/mg 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistDetailResult"),
|
||||
)
|
||||
async def playlist_first(
|
||||
source: str = platform_path("kw", "playlist"),
|
||||
id: str = Path(..., description="歌单 id(或平台歌单链接)", example="123456789"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _playlist_detail(source, id, 1, limit)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/playlist/{source}/{id}/{page}",
|
||||
summary="获取歌单详情与曲目列表(指定页)",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("PlaylistDetailResult"),
|
||||
)
|
||||
async def playlist_page(
|
||||
source: str = platform_path("kw", "playlist"),
|
||||
id: str = Path(..., description="歌单 id"),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _playlist_detail(source, id, page, limit)
|
||||
|
||||
|
||||
# ---------- 链接解析 ----------
|
||||
|
||||
|
||||
async def _parse_handler(kind: str, source: str, link: str) -> Any:
|
||||
module = get_source(source, "parse")
|
||||
impl = getattr(module.parse, f"parse_{kind}", None)
|
||||
if impl is None:
|
||||
raise not_found(f"平台 {source} 不支持解析该类型链接")
|
||||
try:
|
||||
out = await impl(link)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
|
||||
if kind == "song":
|
||||
if out and (out.get("list") or out.get("songs")):
|
||||
songs = out.get("list") or out.get("songs") or []
|
||||
return ok(
|
||||
{
|
||||
"source": source,
|
||||
"type": "playlist",
|
||||
"playlist": out.get("info") or out.get("playlist"),
|
||||
"list": songs,
|
||||
"total": len(songs),
|
||||
}
|
||||
)
|
||||
return ok({"source": source, "type": "song", "song": out})
|
||||
|
||||
songs = (out or {}).get("list") or (out or {}).get("songs") or []
|
||||
return ok(
|
||||
{
|
||||
"source": source,
|
||||
"type": kind,
|
||||
"playlist": (out or {}).get("info") or (out or {}).get("playlist") or out,
|
||||
"list": songs,
|
||||
"total": len(songs),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
_PARSE_KINDS = {"song": "歌曲", "album": "专辑", "playlist": "歌单"}
|
||||
|
||||
|
||||
def _register_parse_routes() -> None:
|
||||
for kind, label in _PARSE_KINDS.items():
|
||||
|
||||
async def handler(
|
||||
source: str = platform_path("kw", "parse.parse_song"),
|
||||
link: str = Body(..., embed=True, description="平台链接或裸 id"),
|
||||
_kind: str = kind,
|
||||
) -> Any:
|
||||
return await _parse_handler(_kind, source, str(link).strip())
|
||||
|
||||
router.add_api_route(
|
||||
f"/parse/{kind}/{{source}}",
|
||||
handler,
|
||||
methods=["POST"],
|
||||
summary=f"解析{label}链接",
|
||||
description="把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;"
|
||||
"5sing 歌曲/歌单、apple/bili/joox/bd/mg 三类、kw/kg/tx/wy 部分支持。",
|
||||
tags=["链接解析"],
|
||||
responses=responses_for("ParseResult"),
|
||||
name=f"parse_{kind}",
|
||||
)
|
||||
|
||||
|
||||
_register_parse_routes()
|
||||
|
||||
|
||||
# ---------- 个人歌单 ----------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/user-playlist/{source}",
|
||||
summary="获取个人歌单列表",
|
||||
description="需先登录(见 /login/*)。当前仅 tx/wy 有完整实现(见 /sources 的 userPlaylist 能力位);"
|
||||
"未登录或平台不支持时返回 404/502。",
|
||||
tags=["歌单"],
|
||||
responses=responses_for("UserPlaylistResult"),
|
||||
)
|
||||
async def user_playlist(
|
||||
source: str = platform_path("wy", "user_playlist.get_list"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
module = get_source(source, "user_playlist.get_list")
|
||||
try:
|
||||
result = await module.user_playlist.get_list(page, limit)
|
||||
except Exception as err:
|
||||
raise upstream(str(err)) from err
|
||||
items = result if isinstance(result, list) else (result or {}).get("list") or []
|
||||
return ok({"source": source, "list": items, "page": page, "limit": limit})
|
||||
@@ -0,0 +1,77 @@
|
||||
"""登录态自动续期 —— 启动后延时刷一轮 + 每 24h 一次(对应原 src/refresh.ts)。
|
||||
|
||||
各平台凭据独立处理:未登录跳过,单个平台失败不影响另一个。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.auth import kg as kg_auth
|
||||
from app.sdk.auth import tx as tx_auth
|
||||
|
||||
INTERVAL_SECONDS = 24 * 60 * 60
|
||||
# 启动后延迟触发,避开启动期与冒烟测试的快速起停
|
||||
STARTUP_DELAY_SECONDS = 10
|
||||
|
||||
_running = False
|
||||
_tasks: list[asyncio.Task[None]] = []
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[{datetime.now().isoformat(timespec='milliseconds')}] [登录续期] {msg}")
|
||||
|
||||
|
||||
async def refresh_all() -> dict[str, Any]:
|
||||
"""手动/定时共用的续期入口,返回各平台结果(skipped / refreshed / error)。"""
|
||||
global _running
|
||||
if _running:
|
||||
return {"skipped": True}
|
||||
_running = True
|
||||
try:
|
||||
result: dict[str, Any] = {}
|
||||
for name, fn in (("kg", kg_auth.refresh_login), ("tx", tx_auth.refresh_login)):
|
||||
try:
|
||||
res = await fn()
|
||||
result[name] = res
|
||||
if isinstance(res, dict) and res.get("skipped"):
|
||||
_log(f"{name}: 未登录,跳过")
|
||||
else:
|
||||
_log(f"{name}: 登录态已刷新")
|
||||
except NotImplementedError:
|
||||
result[name] = {"skipped": True, "msg": "尚未移植"}
|
||||
except Exception as err: # noqa: BLE001 - 单平台失败不影响其它平台
|
||||
result[name] = {"error": str(err)}
|
||||
_log(f"{name}: {err}")
|
||||
return result
|
||||
finally:
|
||||
_running = False
|
||||
|
||||
|
||||
async def _loop() -> None:
|
||||
await asyncio.sleep(STARTUP_DELAY_SECONDS)
|
||||
while True:
|
||||
await refresh_all()
|
||||
await asyncio.sleep(INTERVAL_SECONDS)
|
||||
|
||||
|
||||
def start_refresh_scheduler() -> None:
|
||||
"""注册定时任务(在应用 startup 时调用)。"""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError: # 无事件循环(如导入期)时忽略
|
||||
return
|
||||
task = loop.create_task(_loop())
|
||||
_tasks.append(task)
|
||||
_log(f"已启动自动续期(启动后 {STARTUP_DELAY_SECONDS}s 首刷,此后每 {INTERVAL_SECONDS // 3600}h 一次)")
|
||||
|
||||
|
||||
async def stop_refresh_scheduler() -> None:
|
||||
while _tasks:
|
||||
task = _tasks.pop()
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
@@ -0,0 +1,51 @@
|
||||
"""系统路由 —— 健康检查与平台能力清单。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.http.envelope import ok
|
||||
from app.sdk import SUPPORTED_SOURCES, support_quality
|
||||
from app.sdk.platforms._base import capability_map
|
||||
from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter(tags=["系统"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
summary="健康检查",
|
||||
description="返回运行时长与已接入平台数,可用于探活与监控。",
|
||||
)
|
||||
async def health(request: Request) -> Any:
|
||||
started_at = getattr(request.app.state, "started_at", None) or time.time()
|
||||
return ok(
|
||||
{
|
||||
"status": "ok",
|
||||
"uptime": round(time.time() - started_at),
|
||||
"platforms": len(SUPPORTED_SOURCES),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/sources",
|
||||
summary="获取支持的平台",
|
||||
description="返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList/"
|
||||
"singer/album/albumSearch/playlist/playlistSearch/playlistCategories/parse/userPlaylist)。"
|
||||
"音质为 SDK 官方直连接口的支持范围。",
|
||||
)
|
||||
async def sources() -> Any:
|
||||
platforms: list[dict[str, Any]] = []
|
||||
for item in SUPPORTED_SOURCES:
|
||||
module = item["module"]
|
||||
platforms.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item["name"],
|
||||
"qualities": support_quality.get(item["id"], []),
|
||||
"capabilities": capability_map(module),
|
||||
}
|
||||
)
|
||||
return ok({"platforms": platforms})
|
||||
Reference in new issue
Block a user