feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
"""网易云个人歌单(移植自 sdk/wy/userPlaylist.ts,上游 music-lib/netease/user_playlist.go)。
需登录 cookie MUSIC_U(app/sdk/auth_store.py 的 get_credentials('wy')):
未登录直接抛清晰错误,不返回空列表 —— 空列表会被前端当成「用户没有歌单」。
1) weapi/nuser/account/get → 当前用户 userId
2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_credentials, get_wy_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME
from app.sdk.platforms.wy.utils.crypto import weapi
PLAYLIST_LINK = "https://music.163.com/#/playlist?id={}"
def playlist_link(playlist_id: str) -> str:
return PLAYLIST_LINK.format(playlist_id)
def read_cookie() -> str:
"""优先用 musicU 拼 MUSIC_U,其次退回已存 cookie / 通用 cookie。"""
cred = get_credentials("wy") or {}
music_u = str(cred.get("musicU") or "").strip()
if music_u:
return f"MUSIC_U={music_u}"
return str(cred.get("cookie") or get_wy_cookie()).strip()
async def _weapi_post(url: str, data: dict[str, Any], cookie: str) -> dict[str, Any]:
resp = await http_fetch(
url,
method="post",
headers={
"User-Agent": UA_LINUX_CHROME,
"Referer": "https://music.163.com/",
"origin": "https://music.163.com",
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookie,
},
form=weapi(data),
timeout=15000,
)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError("wy 个人歌单接口请求失败")
return body
async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]:
"""个人歌单列表。未登录 / 登录态失效抛 RuntimeError,不返回空列表。"""
cookie = read_cookie()
if not cookie:
raise RuntimeError("wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云")
safe_page = max(1, int(page or 1))
safe_limit = int(limit or 30)
if safe_limit <= 0:
safe_limit = 30
if safe_limit > 100:
safe_limit = 100
account = await _weapi_post("https://music.163.com/weapi/nuser/account/get", {"csrf_token": ""}, cookie)
profile = account.get("profile") or {}
if account.get("code") != 200 or not profile.get("userId"):
raise RuntimeError(f"wy 登录态无效或已过期 (code {account.get('code', 'unknown')})")
uid = profile.get("userId")
nickname = profile.get("nickname") or ""
body = await _weapi_post(
"https://music.163.com/weapi/user/playlist",
{
"uid": uid,
"limit": safe_limit,
"offset": (safe_page - 1) * safe_limit,
"includeVideo": True,
"csrf_token": "",
},
cookie,
)
if body.get("code") != 200:
raise RuntimeError(f"wy 个人歌单获取失败 (code {body.get('code')})")
rows: list[dict[str, Any]] = []
for item in body.get("playlist") or []:
if not isinstance(item, dict):
continue
id_ = str(item.get("id") or "").strip()
if not id_:
continue
play_count = int(item.get("playCount") or 0)
creator = str((item.get("creator") or {}).get("nickname") or "").strip() or nickname
rows.append(
{
"id": id_,
"name": item.get("name") or "",
"cover": item.get("coverImgUrl") or "",
"trackCount": int(item.get("trackCount") or 0),
"playCount": play_count,
"play_count": format_play_count(play_count),
"creator": creator,
"description": item.get("description") or "",
"subscribed": bool(item.get("subscribed")),
"link": playlist_link(id_),
"source": "wy",
}
)
return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "wy"}
get_user_playlists = get_list