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:
snowy-yang committed 2026-10-23 23:40:55 +08:00
1 parent 39565a3913
commit 0df18f048b
18 files changed
+498 -205

No files matched your search

+6 -2
View File
@@ -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 直接运行,
+6 -4
View File
@@ -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("重定向次数过多")
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 能力位;"
-13
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
-11
View File
@@ -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,
-7
View File
@@ -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
View File
@@ -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 一次。
## 响应示例
-1
View File
@@ -22,7 +22,6 @@ dev = [
"pytest-asyncio>=0.24",
"ruff>=0.6",
"pyright>=1.1.380",
"httpx>=0.27",
]
[tool.ruff]
+2 -2
View File
@@ -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 %*
)
+19
View File
@@ -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)
+257
View File
@@ -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"
Generated
-39
View File
@@ -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" },