fix(server): 修复路由遮蔽、流式背压与鉴权限流顺序
- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
This commit is contained in:
1 parent
39565a3913
commit
0df18f048b
18 files changed
+498
-205
No files matched your search
@@ -125,7 +125,9 @@ granian 也支持显式指定与默认不同的循环(如 Unix 上 `--loop uvl
|
||||
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
|
||||
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
|
||||
| `POST /login/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
|
||||
| `POST /login/{mg,bd,joox,apple,5sing}` | 绑定对应平台 cookie(body `{"cookie": "..."}`;apple 需含 `media-user-token`、joox 通常需 `wmid`/`session_key`;服务端不做在线校验) |
|
||||
| `DELETE /login/{wy,kg,tx,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) |
|
||||
| `DELETE /login/{mg,bd,joox,apple,5sing}` | 清除对应平台 cookie 凭据 |
|
||||
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
|
||||
| `GET /sources` | 平台列表(音质与能力) |
|
||||
| `GET /health` | 健康检查 |
|
||||
@@ -186,7 +188,7 @@ app/ Python 应用(FastAPI,Python 3.12)
|
||||
config.py 环境变量、.env 加载、端口/鉴权/限流/数据目录
|
||||
routes/
|
||||
music.py 业务路由(搜索/取流/歌词/封面/榜单/歌单/专辑/歌手/解析/流代理)
|
||||
login.py 登录路由(wy/kg/tx/bili 扫码与短信账密、xm/me cookie、lr 账密)
|
||||
login.py 登录路由(wy/kg/tx/bili 扫码与短信账密、xm/me cookie、lr 账密、mg/bd/joox/apple/5sing cookie)
|
||||
refresh.py 登录态自动续期(启动后延时首轮 + 每 24h,kg/tx)
|
||||
system.py /health、/sources
|
||||
http/
|
||||
@@ -214,7 +216,9 @@ docs/
|
||||
PORTING.md TS → Python 平台移植契约(目录/子模块协议/签名差异/验收要求)
|
||||
pyproject.toml uv 项目定义(默认 granian/uvicorn 依赖、ruff、pyright、pytest 配置)
|
||||
tests/
|
||||
test_serve.py 启动器服务器/循环选择逻辑测试(TS 版 smoke 测试尚未移植)
|
||||
conftest.py 测试隔离(临时 DATA_DIR,不触碰真实凭据)
|
||||
test_serve.py 启动器服务器/循环选择逻辑测试
|
||||
test_http.py HTTP 回归(CORS/限流/路由/流式管道/登录存储;TS smoke 尚未全量移植)
|
||||
```
|
||||
|
||||
> 分层依赖单向:`routes` → `sdk`,`sdk` 内部自包含。全源码 `.py` 由 uv 管理的 CPython 3.12 直接运行,
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
|
||||
@@ -167,7 +167,7 @@ def migu_stream_content_type(url: str) -> str:
|
||||
async def stream_decrypted(
|
||||
url: str,
|
||||
feed: Callable[[bytes], bytes],
|
||||
on_chunk: Callable[[bytes], None],
|
||||
on_chunk: Callable[[bytes], Awaitable[None]],
|
||||
*,
|
||||
max_redirects: int = 3,
|
||||
) -> None:
|
||||
@@ -175,12 +175,14 @@ async def stream_decrypted(
|
||||
|
||||
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
|
||||
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
|
||||
on_chunk 为 async,调用方可用有界队列形成背压,避免慢客户端时丢块/死锁。
|
||||
"""
|
||||
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:
|
||||
# trust_env=False:与 common/request 保持一致,不读系统代理(Windows 上 aiohttp 会走 IE 设置)
|
||||
async with aiohttp.ClientSession(timeout=timeout, trust_env=False) 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)
|
||||
@@ -190,6 +192,6 @@ async def stream_decrypted(
|
||||
async for chunk in resp.content.iter_any():
|
||||
plain = feed(chunk)
|
||||
if plain:
|
||||
on_chunk(plain)
|
||||
await on_chunk(plain)
|
||||
return
|
||||
raise RuntimeError("重定向次数过多")
|
||||
@@ -28,7 +28,7 @@ def verify_stream_sign(auth_key: str, path: str, exp: object, sig: object) -> bo
|
||||
"""校验流地址签名(常量时间比较 + 时效窗口)。"""
|
||||
try:
|
||||
exp_num = int(float(str(exp)))
|
||||
except (TypeError, ValueError):
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return False
|
||||
sig_str = str(sig or "")
|
||||
if not sig_str:
|
||||
|
||||
+15
-18
@@ -17,8 +17,9 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI, Request
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
@@ -28,6 +29,7 @@ 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
|
||||
from app.sdk.common.request import close_session
|
||||
|
||||
API_TITLE = "MusicServer 聚合音乐 API"
|
||||
API_DESCRIPTION = (
|
||||
@@ -38,21 +40,6 @@ API_DESCRIPTION = (
|
||||
)
|
||||
|
||||
|
||||
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 版一致)。"""
|
||||
@@ -63,6 +50,7 @@ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||
yield
|
||||
finally:
|
||||
await stop_refresh_scheduler()
|
||||
await close_session()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -89,9 +77,10 @@ def create_app() -> FastAPI:
|
||||
app.state.started_at = time.time()
|
||||
app.state.auth_key = config.auth_key()
|
||||
|
||||
# ---- 中间行为:限流 → 鉴权(限流先计数,401 爆破同样受限) ----
|
||||
app.middleware("http")(make_rate_limiter(config.rate_limit()))
|
||||
# ---- 中间件顺序(后注册者在外层):CORS → no-store → 限流 → 鉴权 ----
|
||||
# 限流必须在鉴权之外:错误秘钥的 401 也要计数,才能真正防爆破。
|
||||
app.middleware("http")(auth_gate)
|
||||
app.middleware("http")(make_rate_limiter(config.rate_limit()))
|
||||
|
||||
@app.middleware("http")
|
||||
async def _no_store(request: Request, call_next):
|
||||
@@ -99,6 +88,14 @@ def create_app() -> FastAPI:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
max_age=600,
|
||||
)
|
||||
|
||||
# ---- 异常处理 ----
|
||||
@app.exception_handler(ApiError)
|
||||
async def _api_error(_: Request, exc: ApiError):
|
||||
|
||||
+39
-2
@@ -9,12 +9,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.http.api_error import ApiError, upstream
|
||||
from app.http.api_error import ApiError, not_found, 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
|
||||
from fastapi import APIRouter, Body, Path
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -331,6 +331,43 @@ async def login_refresh() -> Any:
|
||||
return ok(await refresh_all())
|
||||
|
||||
|
||||
# 这些平台没有独立登录协议,只能从浏览器/App 复制 cookie(README 平台表已声明 cookie 登录)。
|
||||
# 白名单限定,避免动态路由把任意平台写进凭据文件。
|
||||
_COOKIE_LOGIN_SOURCES = {"mg", "bd", "joox", "apple", "5sing"}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/{source}",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="绑定平台 cookie",
|
||||
description="mg/bd/joox/apple/5sing 用:从浏览器或 App 复制完整 cookie 字符串并持久化。"
|
||||
"apple 需包含 media-user-token;joox 通常需 wmid/session_key。服务端不做在线校验。",
|
||||
)
|
||||
async def cookie_bind(
|
||||
source: str = Path(..., description="平台 id(mg/bd/joox/apple/5sing)", examples=["bd"]),
|
||||
cookie: str = Body(..., embed=True, description="完整 cookie 字符串"),
|
||||
) -> Any:
|
||||
if source not in _COOKIE_LOGIN_SOURCES:
|
||||
raise not_found(f"不支持的 cookie 登录平台: {source}")
|
||||
value = str(cookie or "").strip()
|
||||
if not value:
|
||||
raise ApiError(422, "cookie 不能为空")
|
||||
set_credentials(source, {"cookie": value})
|
||||
return ok({"logged": True})
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/login/{source}",
|
||||
tags=[LOGIN_TAG],
|
||||
summary="退出平台登录(cookie 平台)",
|
||||
)
|
||||
async def cookie_logout(source: str = Path(..., description="平台 id(mg/bd/joox/apple/5sing)")) -> Any:
|
||||
if source not in _COOKIE_LOGIN_SOURCES:
|
||||
raise not_found(f"不支持的 cookie 登录平台: {source}")
|
||||
clear_credentials(source)
|
||||
return ok({"logged": False})
|
||||
|
||||
|
||||
def _stored_status(package: str) -> dict[str, Any]:
|
||||
"""按已存凭据判断登录态(xm/me/bd/joox/apple/5sing 用)。"""
|
||||
cred = get_credentials(package)
|
||||
|
||||
+89
-72
@@ -6,9 +6,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.http.api_error import not_found, upstream
|
||||
from app.http.api_error import ApiError, not_found, upstream
|
||||
from app.http.envelope import ok
|
||||
from app.http.mg_stream import (
|
||||
Z3DStreamDecoder,
|
||||
@@ -18,7 +21,7 @@ from app.http.mg_stream import (
|
||||
take_migu_stream,
|
||||
)
|
||||
from app.http.stream_sign import sign_stream
|
||||
from app.sdk import SOURCE_MODULES, SUPPORTED_SOURCES, find_music
|
||||
from app.sdk import SOURCE_MODULES, SUPPORTED_SOURCES, find_music, support_quality
|
||||
from app.sdk.types import cap as get_cap
|
||||
from fastapi import APIRouter, Body, Path, Query, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
@@ -27,15 +30,13 @@ router = APIRouter()
|
||||
|
||||
QUALITIES = ["64k", "128k", "320k", "flac", "flac24bit"]
|
||||
|
||||
|
||||
def _allowed_qualities(source: str) -> list[str]:
|
||||
"""通用档位 + 平台自定义档位(如 5sing 的 sq/hq/lq)。"""
|
||||
return list(dict.fromkeys([*QUALITIES, *support_quality.get(source, [])]))
|
||||
|
||||
|
||||
# ---- 常用 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]:
|
||||
@@ -75,7 +76,7 @@ def get_source(source: str, capability: str | None = None) -> Any:
|
||||
|
||||
|
||||
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")
|
||||
return Path(..., description=f"{description}{f',需支持 {capability} 能力' if capability else ''}", examples=["kw"])
|
||||
|
||||
|
||||
# ---------- 搜索 ----------
|
||||
@@ -125,8 +126,8 @@ async def search_platform(platform: str, word: str, page: int, limit: int) -> li
|
||||
)
|
||||
async def search_first(
|
||||
source: str = platform_path("kw", "search"),
|
||||
keyword: str = Path(..., description="搜索关键词(URL 编码的中文可直接使用)", example="稻香"),
|
||||
singer: str | None = Query(None, description="歌手名(与关键词拼接搜索)", example="周杰伦"),
|
||||
keyword: str = Path(..., description="搜索关键词(URL 编码的中文可直接使用)", examples=["稻香"]),
|
||||
singer: str | None = Query(None, description="歌手名(与关键词拼接搜索)", examples=["周杰伦"]),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return ok(await _search_handler(source, keyword, 1, singer, limit))
|
||||
@@ -141,7 +142,7 @@ async def search_first(
|
||||
)
|
||||
async def search_page(
|
||||
source: str = platform_path("kw", "search"),
|
||||
keyword: str = Path(..., description="搜索关键词", example="稻香"),
|
||||
keyword: str = Path(..., description="搜索关键词", examples=["稻香"]),
|
||||
page: int = Path(..., ge=1, description="页码"),
|
||||
singer: str | None = Query(None, description="歌手名"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
@@ -155,15 +156,15 @@ async def search_page(
|
||||
description="按歌手、歌名、专辑、时长综合排序的精确匹配。source 为平台 id(只在单平台内匹配)或 all"
|
||||
"(跨平台聚合,可用 excludeSource 跳过某平台;聚合自动排除 xm/me/qt/lr 有声平台)。",
|
||||
tags=["搜索"],
|
||||
responses=responses_for("FindResult", not_found=False),
|
||||
responses=responses_for("FindResult"),
|
||||
)
|
||||
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"),
|
||||
source: str = Path(..., description="平台 id 或 all", examples=["all"]),
|
||||
keyword: str = Path(..., description="歌名", examples=["稻香"]),
|
||||
singer: str | None = Query(None, description="歌手名", examples=["周杰伦"]),
|
||||
albumName: str | None = Query(None, description="专辑名", examples=["魔杰座"]),
|
||||
interval: str | None = Query(None, description="时长(mm:ss),用于时长校验", examples=["03:43"]),
|
||||
excludeSource: str | None = Query(None, description="source=all 时跳过该平台", examples=["kw"]),
|
||||
) -> Any:
|
||||
kwargs: dict[str, Any] = {
|
||||
"name": keyword,
|
||||
@@ -172,8 +173,10 @@ async def find(
|
||||
"interval": interval,
|
||||
}
|
||||
if source != "all":
|
||||
get_source(source, "music_search")
|
||||
kwargs["only"] = source
|
||||
elif excludeSource:
|
||||
get_source(excludeSource, "music_search")
|
||||
kwargs["source"] = excludeSource
|
||||
try:
|
||||
return ok({"list": await find_music(**kwargs)})
|
||||
@@ -198,17 +201,16 @@ async def find(
|
||||
async def music_url(
|
||||
request: Request,
|
||||
source: str = platform_path("kw", "url"),
|
||||
id: str = Path(..., description="平台歌曲 id", example="28423286"),
|
||||
id: str = Path(..., description="平台歌曲 id", examples=["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"),
|
||||
interval: str | None = Query(None, description="时长 mm:ss(酷我兜底匹配用)", examples=["04:17"]),
|
||||
) -> Any:
|
||||
if quality not in QUALITIES:
|
||||
from app.http.api_error import ApiError
|
||||
|
||||
raise ApiError(422, f"quality: 不支持的音质 {quality}(可选:{', '.join(QUALITIES)})")
|
||||
allowed = _allowed_qualities(source)
|
||||
if quality not in allowed:
|
||||
raise ApiError(422, f"quality: 不支持的音质 {quality}({source} 可选:{', '.join(allowed)})")
|
||||
|
||||
module = get_source(source, "get_music_url")
|
||||
song_info = {"songmid": id, "hash": id, "copyrightId": id, "albumId": albumId or "", "_types": {}}
|
||||
@@ -226,7 +228,8 @@ async def music_url(
|
||||
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}"
|
||||
base = str(request.base_url).rstrip("/")
|
||||
raw_url = f"{base}{raw_url}{suffix}"
|
||||
payload: dict[str, Any] = {"type": result.get("type") or quality, "url": raw_url}
|
||||
if via != source:
|
||||
payload["via"] = via
|
||||
@@ -297,29 +300,36 @@ def _stream_response(url: str, decoder: Z3DStreamDecoder) -> StreamingResponse:
|
||||
"""异步队列桥接:下载协程生产解密块,StreamingResponse 消费块。
|
||||
|
||||
解密按块增量进行(跨块保持全局偏移),整段音频可达上百 MB,不能读进内存。
|
||||
队列有界并由生产者 ``await queue.put`` 形成背压;客户端断开时整个任务被取消。
|
||||
上游/解密失败会作为异常项传给消费者并中断响应,避免把截断音频伪装成正常结束。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=32)
|
||||
queue: asyncio.Queue[bytes | Exception | 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 # 日志留痕,只能中断连接;把错误留在服务端日志
|
||||
await stream_decrypted(url, decoder.feed, queue.put)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - 响应头已发出,只能把错误交给消费者中断连接
|
||||
print(f"[stream] 上游读取中断: {err!r}")
|
||||
finally:
|
||||
queue.put_nowait(None)
|
||||
await queue.put(err)
|
||||
return
|
||||
await queue.put(None)
|
||||
|
||||
async def body():
|
||||
task = asyncio.create_task(produce())
|
||||
try:
|
||||
while True:
|
||||
chunk = await queue.get()
|
||||
if chunk is None:
|
||||
item = await queue.get()
|
||||
if item is None:
|
||||
break
|
||||
yield chunk
|
||||
if isinstance(item, Exception):
|
||||
raise item
|
||||
yield item
|
||||
finally:
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
return StreamingResponse(
|
||||
body(),
|
||||
@@ -405,7 +415,7 @@ async def _board_handler(source: str, board_id: str, page: int) -> Any:
|
||||
async def board_first(
|
||||
source: str = platform_path("kw", "boards"),
|
||||
boardId: str = Path(
|
||||
..., description="榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)", example="kw__93"
|
||||
..., description="榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)", examples=["kw__93"]
|
||||
),
|
||||
) -> Any:
|
||||
return await _board_handler(source, boardId, 1)
|
||||
@@ -602,12 +612,38 @@ async def _album_detail(source: str, album_id: str, page: int, limit: int) -> An
|
||||
)
|
||||
async def album_first(
|
||||
source: str = platform_path("kg", "album"),
|
||||
id: str = Path(..., description="平台专辑 id(搜索结果中的 albumId)", example="123456"),
|
||||
id: str = Path(..., description="平台专辑 id(搜索结果中的 albumId)", examples=["123456"]),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _album_detail(source, id, 1, limit)
|
||||
|
||||
|
||||
# 注意:静态段路由必须先于 `/{id}/{page}` 注册。Starlette 按注册顺序匹配,
|
||||
# 否则 /album/{source}/search/{keyword} 会被 /album/{source}/{id}/{page} 捕获,
|
||||
# keyword 落到 page 参数上直接 422。
|
||||
@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="专辑名关键词", examples=["魔杰座"]),
|
||||
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(
|
||||
"/album/{source}/{id}/{page}",
|
||||
summary="获取专辑详情与曲目列表(指定页)",
|
||||
@@ -623,29 +659,6 @@ async def album_page(
|
||||
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})
|
||||
|
||||
|
||||
# ---------- 歌单 ----------
|
||||
|
||||
|
||||
@@ -659,7 +672,7 @@ async def album_search(
|
||||
)
|
||||
async def playlist_search(
|
||||
source: str = platform_path("kw", "playlist.search_playlist"),
|
||||
keyword: str = Path(..., description="歌单关键词", example="华语"),
|
||||
keyword: str = Path(..., description="歌单关键词", examples=["华语"]),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
@@ -713,7 +726,7 @@ async def _category_playlists(source: str, category_id: str, page: int, limit: i
|
||||
)
|
||||
async def playlist_category_items(
|
||||
source: str = platform_path("kw", "playlist.get_categories"),
|
||||
categoryId: str = Path(..., description="分类 id(分类列表返回)", example="华语"),
|
||||
categoryId: str = Path(..., description="分类 id(分类列表返回)", examples=["华语"]),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
@@ -755,7 +768,7 @@ async def _playlist_detail(source: str, playlist_id: str, page: int, limit: int)
|
||||
)
|
||||
async def playlist_first(
|
||||
source: str = platform_path("kw", "playlist"),
|
||||
id: str = Path(..., description="歌单 id(或平台歌单链接)", example="123456789"),
|
||||
id: str = Path(..., description="歌单 id(或平台歌单链接)", examples=["123456789"]),
|
||||
limit: int = Query(20, ge=1, le=100, description="每页条数"),
|
||||
) -> Any:
|
||||
return await _playlist_detail(source, id, 1, limit)
|
||||
@@ -818,19 +831,23 @@ async def _parse_handler(kind: str, source: str, link: str) -> Any:
|
||||
_PARSE_KINDS = {"song": "歌曲", "album": "专辑", "playlist": "歌单"}
|
||||
|
||||
|
||||
def _register_parse_routes() -> None:
|
||||
for kind, label in _PARSE_KINDS.items():
|
||||
def _make_parse_handler(kind: str) -> Callable[..., Any]:
|
||||
"""闭包固定解析类型 —— 不能让 kind 出现在 handler 签名里,否则 FastAPI 会把它当 query 参数。"""
|
||||
|
||||
async def handler(
|
||||
source: str = platform_path("kw", "parse.parse_song"),
|
||||
source: str = platform_path("kw", f"parse.parse_{kind}"),
|
||||
link: str = Body(..., embed=True, description="平台链接或裸 id"),
|
||||
_kind: str = kind,
|
||||
) -> Any:
|
||||
return await _parse_handler(_kind, source, str(link).strip())
|
||||
return await _parse_handler(kind, source, str(link).strip())
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _register_parse_routes() -> None:
|
||||
for kind, label in _PARSE_KINDS.items():
|
||||
router.add_api_route(
|
||||
f"/parse/{kind}/{{source}}",
|
||||
handler,
|
||||
_make_parse_handler(kind),
|
||||
methods=["POST"],
|
||||
summary=f"解析{label}链接",
|
||||
description="把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;"
|
||||
|
||||
@@ -25,7 +25,6 @@ __all__ = [
|
||||
"find_music",
|
||||
"format_play_count",
|
||||
"format_play_time",
|
||||
"init",
|
||||
"search_music",
|
||||
"size_formate",
|
||||
"sources",
|
||||
@@ -113,18 +112,6 @@ def _quality_table() -> dict[str, list[str]]:
|
||||
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
|
||||
|
||||
|
||||
+11
-7
@@ -153,12 +153,14 @@ async def get_token(retry_num: int = 0) -> KwToken:
|
||||
cached = _state["token"]
|
||||
if cached is not None:
|
||||
return cached
|
||||
# 重试必须在锁内用循环完成:asyncio.Lock 不可重入,锁内递归会永久死锁。
|
||||
last_error: Exception | None = None
|
||||
for _attempt in range(max(0, retry_num), 3):
|
||||
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)
|
||||
except Exception as err: # noqa: BLE001 - 网络抖动重试,最终统一抛出
|
||||
last_error = err
|
||||
continue
|
||||
|
||||
found: list[tuple[str, str]] = []
|
||||
for raw in cookies:
|
||||
@@ -168,14 +170,16 @@ async def get_token(retry_num: int = 0) -> KwToken:
|
||||
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)
|
||||
last_error = RuntimeError("响应中没有 Hm_Iuvt_* cookie")
|
||||
continue
|
||||
|
||||
token = KwToken(found[0][0], found[0][1])
|
||||
_state["token"] = token
|
||||
return token
|
||||
|
||||
detail = f": {last_error}" if last_error else ""
|
||||
raise RuntimeError(f"获取酷我token失败{detail}") from last_error
|
||||
|
||||
|
||||
async def get_url_by_playurl(songmid: str, token: KwToken, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""官方 playUrl 接口(需 Secret 签名)。签名/token 失效时刷新后重试一次。"""
|
||||
|
||||
+7
-4
@@ -62,9 +62,9 @@ def _extract_login_cookie(set_cookie: list[str]) -> tuple[str, str]:
|
||||
return joined, match.group(1) if match else ""
|
||||
|
||||
|
||||
async def _fetch_profile() -> dict[str, Any]:
|
||||
"""登录成功后拉取账号资料。"""
|
||||
body, _ = await _request_weapi("/api/w/nuser/account/get", {})
|
||||
async def _fetch_profile(cookie: str | None = None) -> dict[str, Any]:
|
||||
"""登录成功后拉取账号资料(cookie 显式传入新登录会话,避免首次登录读到旧 cookie)。"""
|
||||
body, _ = await _request_weapi("/api/w/nuser/account/get", {}, cookie)
|
||||
body = as_dict(body)
|
||||
account = as_dict(body.get("account"))
|
||||
profile = as_dict(body.get("profile"))
|
||||
@@ -81,7 +81,10 @@ async def _apply_login(set_cookie: list[str]) -> dict[str, Any]:
|
||||
cookie, music_u = _extract_login_cookie(set_cookie)
|
||||
if not music_u:
|
||||
raise RuntimeError("登录响应中缺少 MUSIC_U")
|
||||
profile = await _fetch_profile()
|
||||
try:
|
||||
profile = await _fetch_profile(cookie)
|
||||
except Exception: # noqa: BLE001 - 资料拉取失败不影响已成功的登录,返回空资料
|
||||
profile = {"userid": None, "nickname": None, "avatar": None, "vipType": None}
|
||||
set_credentials("wy", {"cookie": cookie, "musicU": music_u, **profile})
|
||||
return profile
|
||||
|
||||
|
||||
+41
-19
@@ -14,6 +14,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.config import data_dir
|
||||
@@ -21,41 +22,58 @@ from app.config import data_dir
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, Any] = {}
|
||||
_loaded = False
|
||||
_cache_path: str | None = None
|
||||
_cache_mtime: int | None = None
|
||||
|
||||
|
||||
def _data_file():
|
||||
def _data_file() -> Path:
|
||||
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
|
||||
def _mtime(path: Path) -> int | None:
|
||||
try:
|
||||
_cache = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (ValueError, OSError) as err:
|
||||
# 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
|
||||
# 否则下一次 set_credentials 就会用空表覆盖掉本可恢复的文件。
|
||||
return path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _backup_corrupt(path: Path, err: Exception) -> None:
|
||||
"""损坏文件先备份再重置,避免后续 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}")
|
||||
|
||||
|
||||
def _load() -> dict[str, Any]:
|
||||
"""读取凭据缓存;文件被外部修改(mtime 变化)时自动重载。"""
|
||||
global _cache, _loaded, _cache_path, _cache_mtime
|
||||
path = _data_file()
|
||||
mtime = _mtime(path)
|
||||
if _loaded and _cache_path == str(path) and _cache_mtime == mtime:
|
||||
return _cache
|
||||
|
||||
try:
|
||||
if mtime is None:
|
||||
_cache = {}
|
||||
else:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("auth.json 顶层必须是 JSON 对象")
|
||||
_cache = data
|
||||
except (ValueError, OSError) as err:
|
||||
_backup_corrupt(path, err)
|
||||
_cache = {}
|
||||
_loaded = True
|
||||
_cache_path = str(path)
|
||||
_cache_mtime = _mtime(path)
|
||||
return _cache
|
||||
|
||||
|
||||
def _save(data: dict[str, Any]) -> None:
|
||||
global _cache, _loaded
|
||||
_cache = data
|
||||
_loaded = True
|
||||
global _cache, _loaded, _cache_path, _cache_mtime
|
||||
path = _data_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
||||
@@ -70,6 +88,10 @@ def _save(data: dict[str, Any]) -> None:
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
_cache = data
|
||||
_loaded = True
|
||||
_cache_path = str(path)
|
||||
_cache_mtime = _mtime(path)
|
||||
|
||||
|
||||
def get_credentials(platform: str) -> dict[str, Any] | None:
|
||||
@@ -79,7 +101,7 @@ def get_credentials(platform: str) -> dict[str, Any] | None:
|
||||
|
||||
def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
|
||||
with _lock:
|
||||
data = _load()
|
||||
data = dict(_load())
|
||||
merged = {**data.get(platform, {}), **credentials, "updatedAt": _iso_now()}
|
||||
data[platform] = merged
|
||||
_save(data)
|
||||
@@ -87,7 +109,7 @@ def set_credentials(platform: str, credentials: dict[str, Any]) -> None:
|
||||
|
||||
def clear_credentials(platform: str) -> None:
|
||||
with _lock:
|
||||
data = _load()
|
||||
data = dict(_load())
|
||||
data.pop(platform, None)
|
||||
_save(data)
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ from app.sdk.platforms.apple.util import (
|
||||
extract_apple_id,
|
||||
fetch_apple_song_resource,
|
||||
get_apple_config,
|
||||
get_apple_token,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,15 +30,6 @@ 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
|
||||
|
||||
@@ -120,7 +110,6 @@ def status() -> dict[str, Any]:
|
||||
MODULE = Platform(
|
||||
id="apple",
|
||||
name="Apple Music",
|
||||
init=_init,
|
||||
music_search=music_search,
|
||||
album=_AppleAlbum,
|
||||
playlist=playlist,
|
||||
|
||||
@@ -25,12 +25,6 @@ from app.sdk.platforms.kg import (
|
||||
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:
|
||||
@@ -61,5 +55,4 @@ MODULE = Platform(
|
||||
get_pic=pic.get_pic,
|
||||
get_music_info=get_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
init=_init,
|
||||
)
|
||||
+5
-3
@@ -65,7 +65,7 @@
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
||||
| GET | `/url/:source/:id` | 播放地址;`?quality=`(通用 64k/128k/320k/flac/flac24bit,5sing 另有 sq/hq/lq,默认 128k)。取流链:SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
||||
| GET | `/stream/:source/:id` | 加密流解密代理(`mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
|
||||
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
|
||||
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
|
||||
@@ -125,11 +125,13 @@
|
||||
| DELETE | `/login/lr` | 退出懒人听书 |
|
||||
| POST | `/login/bili/qr` → `/login/bili/qr/check` | 哔哩哔哩扫码(解锁大会员音质) |
|
||||
| DELETE | `/login/bili` | 退出哔哩哔哩 |
|
||||
| POST | `/login/{mg,bd,joox,apple,5sing}` | 绑定对应平台 cookie(body `{"cookie": "..."}`,服务端不做在线校验) |
|
||||
| DELETE | `/login/{mg,bd,joox,apple,5sing}` | 退出对应平台 |
|
||||
| POST | `/login/refresh` | 手动触发登录态续期(kg/tx) |
|
||||
| GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) |
|
||||
|
||||
- 凭据持久化于 `data/auth.json`(不入库,文件权限 0600),登录后取流与平台请求自动携带。
|
||||
- 自动续期:启动 10s 后对 kg/tx 执行一次,此后每 24h 一次(音源模式下不参与取流,自动续期不启动)。
|
||||
- 凭据持久化于 `data/auth.json`(不入库,文件权限 0600),登录后取流与平台请求自动携带;文件被外部修改时会自动重载。
|
||||
- 自动续期:启动 10s 后对 kg/tx 执行一次,此后每 24h 一次。
|
||||
|
||||
## 响应示例
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ dev = [
|
||||
"pytest-asyncio>=0.24",
|
||||
"ruff>=0.6",
|
||||
"pyright>=1.1.380",
|
||||
"httpx>=0.27",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@echo off
|
||||
if "%1"=="dev" (
|
||||
pnpm run dev
|
||||
uv run python scripts/serve.py --reload
|
||||
) else (
|
||||
pnpm run start
|
||||
uv run python scripts/serve.py %*
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""测试隔离:所有用例使用独立 DATA_DIR,绝不读写真实的 data/auth.json。
|
||||
|
||||
必须在任何 app 模块导入前设置;config.data_dir() 是运行时读取环境变量的。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="music-server-tests-")
|
||||
os.environ.pop("AUTH_KEY", None)
|
||||
os.environ.pop("RATE_LIMIT", None)
|
||||
@@ -0,0 +1,257 @@
|
||||
"""HTTP 层回归测试:CORS、限流顺序、路由遮蔽、流式管道与登录存储。
|
||||
|
||||
测试通过 tests/conftest.py 的临时 DATA_DIR 隔离,不会写真实 data/auth.json。
|
||||
HTTP 请求用 aiohttp 打到同一个进程内的 uvicorn,避免依赖 Starlette TestClient/httpx。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
import app.routes.music as music
|
||||
import app.sdk.apis.kw as kw
|
||||
import app.sdk.auth.wy as wy
|
||||
import app.sdk.auth_store as auth_store
|
||||
import pytest
|
||||
import uvicorn
|
||||
from app.main import create_app
|
||||
from app.sdk import SOURCE_MODULES
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket() as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
class _LiveServer:
|
||||
"""在后台线程运行真实 uvicorn,测试用 aiohttp 访问。"""
|
||||
|
||||
def __init__(self, app: Any) -> None:
|
||||
self.port = _free_port()
|
||||
config = uvicorn.Config(
|
||||
app,
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
log_level="error",
|
||||
access_log=False,
|
||||
lifespan="on",
|
||||
)
|
||||
self.server = uvicorn.Server(config)
|
||||
self.thread = threading.Thread(target=self.server.run, daemon=True)
|
||||
|
||||
def start(self) -> None:
|
||||
self.thread.start()
|
||||
deadline = time.time() + 10
|
||||
while not self.server.started:
|
||||
if not self.thread.is_alive() or time.time() > deadline:
|
||||
raise RuntimeError("测试服务器启动失败")
|
||||
time.sleep(0.01)
|
||||
|
||||
def stop(self) -> None:
|
||||
self.server.should_exit = True
|
||||
self.thread.join(timeout=5)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def live_client(app: Any) -> AsyncIterator[tuple[aiohttp.ClientSession, str]]:
|
||||
server = _LiveServer(app)
|
||||
await asyncio.to_thread(server.start)
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
yield session, f"http://127.0.0.1:{server.port}"
|
||||
finally:
|
||||
await asyncio.to_thread(server.stop)
|
||||
|
||||
|
||||
async def test_cors_headers_and_preflight() -> None:
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.get(f"{base}/health", headers={"Origin": "http://example.com"}) as response:
|
||||
assert response.status == 200
|
||||
assert response.headers["access-control-allow-origin"] == "*"
|
||||
|
||||
async with session.options(
|
||||
f"{base}/health",
|
||||
headers={"Origin": "http://example.com", "Access-Control-Request-Method": "GET"},
|
||||
) as response:
|
||||
assert response.status == 200
|
||||
assert response.headers["access-control-allow-origin"] == "*"
|
||||
|
||||
|
||||
async def test_rate_limit_counts_unauthorized_requests(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AUTH_KEY", "secret")
|
||||
monkeypatch.setenv("RATE_LIMIT", "2")
|
||||
statuses: list[int] = []
|
||||
async with live_client(create_app()) as (session, base):
|
||||
for _ in range(3):
|
||||
async with session.get(f"{base}/health", headers={"X-Server-Key": "wrong"}) as response:
|
||||
statuses.append(response.status)
|
||||
# 前两次进入鉴权返回 401,第三次在鉴权之前被限流拦截。
|
||||
assert statuses == [401, 401, 429]
|
||||
|
||||
|
||||
async def test_album_search_is_not_shadowed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
kg = SOURCE_MODULES["kg"]
|
||||
|
||||
async def fake_search(keyword: str, page: int = 1, limit: int = 20) -> dict:
|
||||
return {"list": [{"name": keyword}], "page": page, "limit": limit, "total": 1}
|
||||
|
||||
monkeypatch.setattr(kg.album, "search_album", fake_search)
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.get(f"{base}/album/kg/search/{quote('魔杰座')}") as response:
|
||||
assert response.status == 200
|
||||
payload = await response.json()
|
||||
assert payload["data"]["list"][0]["name"] == "魔杰座"
|
||||
|
||||
|
||||
async def test_parse_kind_is_not_a_query_parameter(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, str] = {}
|
||||
|
||||
async def fake_parse(kind: str, source: str, link: str) -> dict:
|
||||
seen.update(kind=kind, source=source, link=link)
|
||||
return {"kind": kind}
|
||||
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.get(f"{base}/openapi.json") as response:
|
||||
spec = await response.json()
|
||||
params = spec["paths"]["/parse/song/{source}"]["post"].get("parameters", [])
|
||||
assert "_kind" not in {item["name"] for item in params}
|
||||
|
||||
monkeypatch.setattr(music, "_parse_handler", fake_parse)
|
||||
async with session.post(f"{base}/parse/song/kw?_kind=playlist", json={"link": "x"}) as response:
|
||||
assert response.status == 200
|
||||
|
||||
assert seen == {"kind": "song", "source": "kw", "link": "x"}
|
||||
|
||||
|
||||
async def test_5sing_quality_from_sources_is_accepted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fivesing = SOURCE_MODULES["5sing"]
|
||||
|
||||
async def fake_url(song_info: dict, quality: str, retry_num: int = 0) -> dict:
|
||||
return {"type": "sq", "url": "https://example.com/a.mp3"}
|
||||
|
||||
monkeypatch.setattr(fivesing, "get_music_url", fake_url)
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.get(f"{base}/url/5sing/932557%7Cyc", params={"quality": "sq"}) as response:
|
||||
assert response.status == 200
|
||||
payload = await response.json()
|
||||
assert payload["data"] == {"type": "sq", "url": "https://example.com/a.mp3"}
|
||||
|
||||
|
||||
async def test_find_rejects_unknown_source() -> None:
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.get(f"{base}/find/nope/{quote('稻香')}") as response:
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
async def test_stream_sign_rejects_infinite_exp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AUTH_KEY", "secret")
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.get(f"{base}/stream/mg/x", params={"exp": "inf", "sig": "x"}) as response:
|
||||
assert response.status == 401
|
||||
|
||||
|
||||
async def test_stream_slow_consumer_does_not_hang(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class DummyDecoder:
|
||||
@staticmethod
|
||||
def feed(chunk: bytes) -> bytes:
|
||||
return chunk
|
||||
|
||||
async def fake_stream(url: str, feed, on_chunk) -> None:
|
||||
for _ in range(100):
|
||||
await on_chunk(b"x" * 1024)
|
||||
|
||||
async def consume(response, delay: float) -> int:
|
||||
count = 0
|
||||
async for _ in response.body_iterator:
|
||||
count += 1
|
||||
await asyncio.sleep(delay)
|
||||
return count
|
||||
|
||||
monkeypatch.setattr(music, "stream_decrypted", fake_stream)
|
||||
response = music._stream_response("http://example.com/a.mp3", DummyDecoder()) # type: ignore[arg-type]
|
||||
assert await asyncio.wait_for(consume(response, 0.001), timeout=5) == 100
|
||||
|
||||
|
||||
async def test_stream_error_is_propagated(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class DummyDecoder:
|
||||
@staticmethod
|
||||
def feed(chunk: bytes) -> bytes:
|
||||
return chunk
|
||||
|
||||
async def failing_stream(url: str, feed, on_chunk) -> None:
|
||||
await on_chunk(b"x")
|
||||
raise RuntimeError("upstream boom")
|
||||
|
||||
async def consume(response) -> None:
|
||||
async for _ in response.body_iterator:
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(music, "stream_decrypted", failing_stream)
|
||||
response = music._stream_response("http://example.com/a.mp3", DummyDecoder()) # type: ignore[arg-type]
|
||||
with pytest.raises(RuntimeError, match="upstream boom"):
|
||||
await asyncio.wait_for(consume(response), timeout=5)
|
||||
|
||||
|
||||
async def test_kw_token_retry_does_not_deadlock(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def boom() -> list[str]:
|
||||
raise RuntimeError("network down")
|
||||
|
||||
monkeypatch.setattr(kw, "fetch_home_set_cookie", boom)
|
||||
monkeypatch.setitem(kw._state, "token", None)
|
||||
monkeypatch.setitem(kw._state, "lock", None)
|
||||
with pytest.raises(RuntimeError, match="获取酷我token失败"):
|
||||
await asyncio.wait_for(kw.get_token(), timeout=2)
|
||||
|
||||
|
||||
def test_auth_store_rejects_non_dict_json(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
importlib.reload(auth_store)
|
||||
(tmp_path / "auth.json").write_text("[]", encoding="utf-8")
|
||||
|
||||
assert auth_store.get_credentials("wy") is None
|
||||
assert any(path.name.startswith("auth.json.corrupt-") for path in tmp_path.iterdir())
|
||||
|
||||
|
||||
async def test_cookie_login_bind_and_logout() -> None:
|
||||
async with live_client(create_app()) as (session, base):
|
||||
async with session.post(f"{base}/login/bd", json={"cookie": "BDUSS=test"}) as response:
|
||||
assert response.status == 200
|
||||
|
||||
async with session.get(f"{base}/login/status") as response:
|
||||
payload = await response.json()
|
||||
assert payload["data"]["bd"]["logged"] is True
|
||||
|
||||
async with session.delete(f"{base}/login/bd") as response:
|
||||
assert response.status == 200
|
||||
|
||||
async with session.get(f"{base}/login/status") as response:
|
||||
payload = await response.json()
|
||||
assert payload["data"]["bd"]["logged"] is False
|
||||
|
||||
async with session.post(f"{base}/login/nope", json={"cookie": "x"}) as response:
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
async def test_wy_login_profile_uses_new_cookie(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
seen: dict[str, str | None] = {}
|
||||
|
||||
async def fake_fetch(cookie: str | None = None) -> dict:
|
||||
seen["cookie"] = cookie
|
||||
return {"userid": 1, "nickname": "n", "avatar": None, "vipType": None}
|
||||
|
||||
monkeypatch.setattr(wy, "_fetch_profile", fake_fetch)
|
||||
monkeypatch.setattr(wy, "set_credentials", lambda *args, **kwargs: None)
|
||||
|
||||
profile = await wy._apply_login(["MUSIC_U=abc; Path=/"])
|
||||
assert seen["cookie"] is not None and seen["cookie"].startswith("MUSIC_U=abc")
|
||||
assert profile["nickname"] == "n"
|
||||
@@ -127,15 +127,6 @@ wheels = [
|
||||
{ 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"
|
||||
@@ -313,19 +304,6 @@ 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"
|
||||
@@ -347,21 +325,6 @@ wheels = [
|
||||
{ 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"
|
||||
@@ -426,7 +389,6 @@ dependencies = [
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "httpx" },
|
||||
{ name = "pyright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
@@ -447,7 +409,6 @@ requires-dist = [
|
||||
|
||||
[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" },
|
||||
|
||||
Reference in new issue
Block a user