feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token) - 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期 - request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node) - 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度 - ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,198 @@
|
||||
"""网易云歌单能力(移植自 sdk/wy/playlist.ts,上游 music-lib/netease/playlist.go)。
|
||||
|
||||
搜索/分类:复用 song_list(search=cloudsearch type1000、get_tag=catalogue、get_list=playlist/list)
|
||||
推荐:weapi/personalized/playlist(匿名可用)
|
||||
详情/曲目:复用 song_list.get_list_detail(linuxapi forward),按 page/limit 切片
|
||||
链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import format_play_count
|
||||
from app.sdk.platforms.wy import song_list
|
||||
from app.sdk.platforms.wy.link import parse_netease_link
|
||||
from app.sdk.platforms.wy.utils import weapi_headers
|
||||
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 api_limit() -> int:
|
||||
"""catalogue/list 接口固定页大小(song_list.LIMIT_LIST 运行时读取,避免模块环上的顺序问题)。"""
|
||||
try:
|
||||
return int(song_list.LIMIT_LIST) or 30
|
||||
except (TypeError, ValueError):
|
||||
return 30
|
||||
|
||||
|
||||
def to_summary(item: Any, category_id: str | None = None) -> dict[str, Any] | None:
|
||||
"""歌单摘要:兼容 song_list 既有字段并补 spec 形状(id/name/cover/trackCount/creator/...)。"""
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
id_ = str(item.get("id") or "").strip()
|
||||
if not id_:
|
||||
return None
|
||||
track_count = int(item.get("trackCount") or item.get("total") or 0)
|
||||
play_count = int(item.get("playCount") or 0)
|
||||
summary: dict[str, Any] = {
|
||||
**item,
|
||||
"id": id_,
|
||||
"name": item.get("name") or "",
|
||||
"cover": item.get("cover") or item.get("img") or "",
|
||||
"trackCount": track_count,
|
||||
"playCount": play_count,
|
||||
"play_count": item.get("play_count") or format_play_count(play_count),
|
||||
"creator": item.get("creator") or item.get("author") or "",
|
||||
"description": item.get("description") or item.get("desc") or "",
|
||||
"link": playlist_link(id_),
|
||||
"source": "wy",
|
||||
}
|
||||
if category_id:
|
||||
summary["categoryId"] = category_id
|
||||
return summary
|
||||
|
||||
|
||||
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""歌单搜索(复用 song_list.search)。"""
|
||||
result = await song_list.search(str(keyword or ""), page, limit)
|
||||
rows = [summary for item in result.get("list") or [] if (summary := to_summary(item))]
|
||||
return {"list": rows, "page": page, "limit": limit, "total": result.get("total") or len(rows), "source": "wy"}
|
||||
|
||||
|
||||
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
|
||||
"""歌单详情(复用 song_list.get_list_detail,按 page/limit 切片)。"""
|
||||
detail = await song_list.get_list_detail(str(playlist_id or ""), 1)
|
||||
start = max(0, (page - 1) * limit)
|
||||
songs = detail.get("list") or []
|
||||
return {
|
||||
"source": "wy",
|
||||
"info": detail.get("info"),
|
||||
"list": songs[start : start + limit],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": detail.get("total") or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
|
||||
"""歌单曲目全量(路由在详情签名不匹配时也会用这条兜底)。"""
|
||||
return list((await song_list.get_list_detail(str(playlist_id or ""), 1)).get("list") or [])
|
||||
|
||||
|
||||
async def _safe(coro: Any) -> Any:
|
||||
"""吞掉分类接口异常并退回空列表(原实现是 .catch(() => []))。"""
|
||||
try:
|
||||
return await coro
|
||||
except Exception as err: # noqa: BLE001 - 分类是可选增强,不应让整页 502
|
||||
print(f"[wy] 歌单分类接口失败(已降级为空): {err}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_categories() -> dict[str, Any]:
|
||||
"""歌单分类:摊平 catalogue + hottags,首项固定「全部」。单个接口失败不拖垮整体。"""
|
||||
groups, hot_tags = await asyncio.gather(_safe(song_list.get_tag()), _safe(song_list.get_hot_tag()))
|
||||
hot_set = {str(item.get("id")) for item in hot_tags or [] if isinstance(item, dict)}
|
||||
rows: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
|
||||
seen = {""}
|
||||
for group in groups or []:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
for item in group.get("list") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
id_ = str(item.get("id") or "").strip()
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not id_ or not name or id_ in seen:
|
||||
continue
|
||||
seen.add(id_)
|
||||
rows.append({"id": id_, "name": name, "group": group.get("name") or "", "hot": id_ in hot_set})
|
||||
return {"source": "wy", "list": rows}
|
||||
|
||||
|
||||
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""分类歌单:接口每页 30 条,按入参 limit 重新切片。"""
|
||||
cat = str(category_id or "").strip() or "全部"
|
||||
start = max(0, (page - 1) * limit)
|
||||
page_size = api_limit()
|
||||
api_pages = max(1, -(-(start + limit) // page_size)) # ceil 除法
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
total = 0
|
||||
for api_page in range(1, api_pages + 1):
|
||||
result = await song_list.get_list("hot", cat, api_page)
|
||||
total = int(result.get("total") or total) or total
|
||||
batch = result.get("list") or []
|
||||
items.extend(batch)
|
||||
if len(batch) < page_size:
|
||||
break
|
||||
|
||||
rows = [summary for item in items[start : start + limit] if (summary := to_summary(item, cat))]
|
||||
return {"list": rows, "page": page, "limit": limit, "total": total or len(items), "source": "wy"}
|
||||
|
||||
|
||||
async def get_recommended(page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""推荐歌单(weapi/personalized/playlist)。"""
|
||||
resp = await http_fetch(
|
||||
"https://music.163.com/weapi/personalized/playlist",
|
||||
method="post",
|
||||
headers=weapi_headers(),
|
||||
form=weapi({"limit": 30, "total": True, "n": 1000}),
|
||||
timeout=15000,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 200:
|
||||
raise RuntimeError(f"wy 推荐歌单获取失败 (code {body.get('code', resp.status_code)})")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in body.get("result") 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)
|
||||
copywriter = item.get("copywriter") or ""
|
||||
rows.append(
|
||||
{
|
||||
"id": id_,
|
||||
"name": item.get("name") or "",
|
||||
"cover": item.get("picUrl") or "",
|
||||
"trackCount": int(item.get("trackCount") or 0),
|
||||
"playCount": play_count,
|
||||
"play_count": format_play_count(play_count),
|
||||
"creator": copywriter or "网易云推荐",
|
||||
"description": copywriter,
|
||||
"link": playlist_link(id_),
|
||||
"alg": item.get("alg") or "",
|
||||
"source": "wy",
|
||||
}
|
||||
)
|
||||
if not rows:
|
||||
raise RuntimeError("no recommended playlists found")
|
||||
|
||||
start = max(0, (page - 1) * limit)
|
||||
return {"list": rows[start : start + limit], "page": page, "limit": limit, "total": len(rows), "source": "wy"}
|
||||
|
||||
|
||||
get_recommended_playlists = get_recommended
|
||||
|
||||
|
||||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||||
"""歌单链接解析(裸数字 id 兜底)。"""
|
||||
raw = str(link or "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError("无效的网易云歌单链接")
|
||||
if raw.isdigit():
|
||||
return await get_playlist_detail(raw)
|
||||
kind, id_ = parse_netease_link(raw)
|
||||
if kind != "playlist":
|
||||
raise RuntimeError("无效的网易云歌单链接")
|
||||
return await get_playlist_detail(id_)
|
||||
Reference in new issue
Block a user