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
19 files changed
+534
-241
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+24
-20
@@ -153,28 +153,32 @@ async def get_token(retry_num: int = 0) -> KwToken:
|
||||
cached = _state["token"]
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
cookies = await fetch_home_set_cookie()
|
||||
except Exception as err:
|
||||
if retry_num >= 2:
|
||||
raise RuntimeError(f"获取酷我token失败: {err}") from err
|
||||
return await get_token(retry_num + 1)
|
||||
# 重试必须在锁内用循环完成: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: # noqa: BLE001 - 网络抖动重试,最终统一抛出
|
||||
last_error = err
|
||||
continue
|
||||
|
||||
found: list[tuple[str, str]] = []
|
||||
for raw in cookies:
|
||||
name, _, rest = raw.partition("=")
|
||||
name = name.strip()
|
||||
value = rest.split(";")[0] if rest else ""
|
||||
if _TOKEN_NAME_RE.match(name) and value:
|
||||
found.append((name, value))
|
||||
if not found:
|
||||
if retry_num >= 2:
|
||||
raise RuntimeError("获取酷我token失败")
|
||||
return await get_token(retry_num + 1)
|
||||
found: list[tuple[str, str]] = []
|
||||
for raw in cookies:
|
||||
name, _, rest = raw.partition("=")
|
||||
name = name.strip()
|
||||
value = rest.split(";")[0] if rest else ""
|
||||
if _TOKEN_NAME_RE.match(name) and value:
|
||||
found.append((name, value))
|
||||
if not found:
|
||||
last_error = RuntimeError("响应中没有 Hm_Iuvt_* cookie")
|
||||
continue
|
||||
|
||||
token = KwToken(found[0][0], found[0][1])
|
||||
_state["token"] = token
|
||||
return token
|
||||
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]:
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+46
-24
@@ -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"))
|
||||
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:
|
||||
# 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
|
||||
# 否则下一次 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}")
|
||||
_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,
|
||||
)
|
||||
Reference in new issue
Block a user