Files
MusicServer/app/sdk/platforms/wy/song_list.py
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

336 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""网易云歌单(移植 sdk/wy/songList.ts)。
四条数据源:
* playlist/list(weapi) → get_list:分类歌单列表
* playlist/catalogue + hottags → get_tag / get_hot_tag:分类与热门标签
* linux/forward + v3/playlist/detail → get_list_detail:歌单详情与曲目
* cloudsearch pc(eapi,type=1000) → search:歌单搜索
`getListId` 负责把「歌单链接 / 裸 id / id###token」归一成纯 id。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format, format_play_count, format_play_time, format_singer_name, size_formate
from app.sdk.platforms.wy import music_detail
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, eapi_request, weapi_headers
from app.sdk.platforms.wy.utils.crypto import linuxapi, weapi
LIMIT_LIST = 30
LIMIT_SONG = 100000
SUCCESS_CODE = 200
DEFAULT_COOKIE = "MUSIC_U="
SORT_LIST = [{"name": "最热", "id": "hot"}]
# 路由读 module.song_list.sort_list 取默认排序,必须与路由约定同名(小写)
sort_list = SORT_LIST
RX_LIST_DETAIL_LINK = re.compile(r"^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)")
RX_LIST_DETAIL_LINK2 = re.compile(r"^.+/playlist/(\d+)/\d+/.+$")
# 歌单详情用的 MUSIC_U(原实现把它存在模块状态上:id 里带 ###token 时会被记住)
_state: dict[str, str] = {"cookie": DEFAULT_COOKIE}
async def handle_parse_id(link: str, retry_num: int = 0) -> str:
"""跟随重定向(最多 2 次)后从最终 URL 里取出歌单 id。"""
if retry_num > 2:
raise RuntimeError("link try max num")
resp = await http_fetch(link)
if resp.status_code > 400:
return await handle_parse_id(link, retry_num + 1)
location = resp.header("location")
url = location if location else link
if RX_LIST_DETAIL_LINK.match(url):
return RX_LIST_DETAIL_LINK.sub(r"\1", url)
return RX_LIST_DETAIL_LINK2.sub(r"\1", url)
async def get_list_id(raw_id: str) -> dict[str, str | None]:
"""把歌单链接/裸 id/`id###token` 归一成 {id, cookie}。"""
cookie: str | None = None
id_ = raw_id
if "###" in id_:
url, _, token = id_.partition("###")
id_ = url
cookie = f"MUSIC_U={token}"
if re.search(r"[?&:/]", id_):
if RX_LIST_DETAIL_LINK.match(id_):
id_ = RX_LIST_DETAIL_LINK.sub(r"\1", id_)
elif RX_LIST_DETAIL_LINK2.match(id_):
id_ = RX_LIST_DETAIL_LINK2.sub(r"\1", id_)
else:
id_ = await handle_parse_id(id_)
return {"id": id_, "cookie": cookie}
def _size_of(block: Any) -> str | None:
if isinstance(block, dict) and block.get("size"):
return size_formate(block["size"])
return None
def filter_list_detail(playlist: dict[str, Any], privileges: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""歌单详情曲目 → SongInfo。原实现此处 flac 的 size 恒为 None,保持一样。"""
tracks = playlist.get("tracks") or []
rows: list[dict[str, Any]] = []
for index, item in enumerate(tracks):
if not isinstance(item, dict):
continue
privilege = privileges[index] if index < len(privileges) else None
if not isinstance(privilege, dict) or privilege.get("id") != item.get("id"):
privilege = next((p for p in privileges if isinstance(p, dict) and p.get("id") == item.get("id")), None)
if privilege is None:
continue
types, _types = music_detail.build_types(item, privilege, flac_size=False)
album = item.get("al") or {}
pc = item.get("pc")
if pc:
rows.append(
{
"singer": pc.get("ar") or "",
"name": pc.get("sn") or "",
"albumName": pc.get("alb") or "",
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl") or "",
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
else:
rows.append(
{
"singer": format_singer_name(item.get("ar"), "name"),
"name": item.get("name") or "",
"albumName": album.get("name"),
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return rows
async def get_list_detail(raw_id: str, page: int = 1, try_num: int = 0) -> dict[str, Any]:
"""歌单详情(linux/forward 转发 v3/playlist/detail)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
resolved = await get_list_id(str(raw_id or ""))
id_ = str(resolved["id"] or "")
if resolved["cookie"]:
_state["cookie"] = str(resolved["cookie"])
headers = {"User-Agent": UA_LINUX_CHROME, "Cookie": _state["cookie"]}
form = linuxapi(
{
"method": "POST",
"url": "https://music.163.com/api/v3/playlist/detail",
"params": {"id": id_, "n": LIMIT_SONG, "s": 8},
}
)
resp = await http_fetch("https://music.163.com/api/linux/forward", method="post", headers=headers, form=form)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != SUCCESS_CODE:
return await get_list_detail(id_, page, try_num + 1)
limit = 1000
playlist = body.get("playlist") or {}
track_ids = [track.get("id") for track in playlist.get("trackIds") or [] if isinstance(track, dict)]
privileges = body.get("privileges") or []
if len(track_ids) == len(privileges):
rows = filter_list_detail(playlist, privileges)
else:
range_start = (page - 1) * limit
slice_ids = track_ids[range_start : limit * page]
try:
rows = (await music_detail.get_list(slice_ids))["list"]
except Exception as err:
print(f"[wy] 歌单歌曲详情获取失败将重试({try_num + 1}): {err}")
if str(err) == "try max num":
raise
return await get_list_detail(id_, page, try_num + 1)
creator = playlist.get("creator") or {}
return {
"list": rows,
"page": page,
"limit": limit,
"total": len(track_ids),
"source": "wy",
"info": {
"play_count": format_play_count(playlist.get("playCount")),
"name": playlist.get("name"),
"img": playlist.get("coverImgUrl"),
"desc": playlist.get("description"),
"author": creator.get("nickname"),
},
}
def filter_list(raw_data: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_data or []:
if not isinstance(item, dict):
continue
creator = item.get("creator") or {}
rows.append(
{
"play_count": format_play_count(item.get("playCount")),
"id": str(item.get("id")),
"author": creator.get("nickname"),
"name": item.get("name"),
"time": date_format(item.get("createTime"), "Y-M-D") if item.get("createTime") else "",
"img": item.get("coverImgUrl"),
"grade": item.get("grade"),
"total": item.get("trackCount"),
"desc": item.get("description"),
"source": "wy",
}
)
return rows
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, try_num: int = 0) -> dict[str, Any]:
"""分类歌单列表(weapi/playlist/list)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
form = weapi(
{
"cat": tag_id or "全部",
"order": sort_id,
"limit": LIMIT_LIST,
"offset": LIMIT_LIST * (page - 1),
"total": True,
}
)
resp = await http_fetch(
"https://music.163.com/weapi/playlist/list", method="post", headers=weapi_headers(with_cookie=False), form=form
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, try_num + 1)
try:
total = int(body.get("total") or 0)
except (TypeError, ValueError):
total = 0
return {
"list": filter_list(body.get("playlists")),
"total": total,
"page": page,
"limit": LIMIT_LIST,
"source": "wy",
}
def filter_tag_info(body: dict[str, Any]) -> list[dict[str, Any]]:
categories = body.get("categories") or {}
grouped: dict[Any, list[dict[str, Any]]] = {}
for item in body.get("sub") or []:
if not isinstance(item, dict):
continue
category = item.get("category")
grouped.setdefault(category, []).append(
{
"parent_id": categories.get(category),
"parent_name": categories.get(category),
"id": item.get("name"),
"name": item.get("name"),
"source": "wy",
}
)
return [{"name": categories[key], "list": grouped.get(key), "source": "wy"} for key in categories]
async def get_tag(try_num: int = 0) -> list[dict[str, Any]]:
"""歌单分类树(weapi/playlist/catalogue)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
"https://music.163.com/weapi/playlist/catalogue",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi({}),
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(try_num + 1)
return filter_tag_info(body)
def filter_hot_tag_info(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
tag = (item or {}).get("playlistTag") or {}
if not tag.get("name"):
continue
rows.append({"id": tag.get("name"), "name": tag.get("name"), "source": "wy"})
return rows
async def get_hot_tag(try_num: int = 0) -> list[dict[str, Any]]:
"""热门标签(weapi/playlist/hottags)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
"https://music.163.com/weapi/playlist/hottags",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi({}),
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
# 原实现此处误调 get_tag(返回形状不同),这里同样退回分类接口以保持行为可预期
return await get_tag(try_num + 1)
return filter_hot_tag_info(body.get("tags"))
async def get_tags() -> dict[str, Any]:
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag())
return {"tags": tags, "hotTag": hot_tag, "source": "wy"}
async def get_detail_page_url(raw_id: str) -> str:
resolved = await get_list_id(str(raw_id or ""))
return f"https://music.163.com/#/playlist?id={resolved['id']}"
async def search(text: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(eapi cloudsearch pc type=1000)。失败文案与原文一致:filed。"""
resp = await eapi_request(
"/api/cloudsearch/pc",
{"s": text, "type": 1000, "limit": limit, "total": page == 1, "offset": limit * (page - 1)},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
raise RuntimeError("filed")
result = body.get("result") or {}
return {
"list": filter_list(result.get("playlists")),
"limit": limit,
"total": result.get("playlistCount"),
"source": "wy",
}