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,61 @@
|
||||
"""网易音乐(wy)平台模块 —— 移植自 sdk/wy/index.ts + sdk/apis/wy.ts。
|
||||
|
||||
能力位与原实现对齐:
|
||||
music_search / leaderboard / song_list / hot_search / comment / singer /
|
||||
album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic /
|
||||
get_music_info / get_music_detail_page_url
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms._base import Platform
|
||||
from app.sdk.platforms.wy import (
|
||||
album,
|
||||
comment,
|
||||
hot_search,
|
||||
leaderboard,
|
||||
lyric,
|
||||
music_search,
|
||||
parse,
|
||||
playlist,
|
||||
singer,
|
||||
song_list,
|
||||
user_playlist,
|
||||
)
|
||||
from app.sdk.platforms.wy.music_info import get_music_info
|
||||
from app.sdk.platforms.wy.pic import get_pic
|
||||
|
||||
|
||||
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
|
||||
return f"https://music.163.com/#/song?id={song_info.get('songmid')}"
|
||||
|
||||
|
||||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""取流入口。延迟导入 apis.wy:apis 模块反向依赖 platforms.wy.utils,
|
||||
模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块。"""
|
||||
from app.sdk.apis import wy as wy_api
|
||||
|
||||
return await wy_api.get_music_url(song_info, quality, retry_num)
|
||||
|
||||
|
||||
MODULE = Platform(
|
||||
id="wy",
|
||||
name="网易音乐",
|
||||
music_search=music_search,
|
||||
leaderboard=leaderboard,
|
||||
song_list=song_list,
|
||||
hot_search=hot_search,
|
||||
comment=comment,
|
||||
singer=singer,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
parse=parse,
|
||||
user_playlist=user_playlist,
|
||||
get_music_url=get_music_url,
|
||||
get_lyric=lyric.get_lyric,
|
||||
get_pic=get_pic,
|
||||
get_music_info=get_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
)
|
||||
@@ -0,0 +1,172 @@
|
||||
"""网易云专辑(移植自 sdk/wy/album.ts,上游 music-lib/netease/album.go + fetchAlbumDetail)。
|
||||
|
||||
搜索:cloudsearch pc type=10(eapi 通道)
|
||||
详情/曲目:weapi/v1/album/<id>(匿名可用,登录 cookie 可选)
|
||||
链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
|
||||
from app.sdk.platforms.wy.link import parse_netease_link
|
||||
from app.sdk.platforms.wy.utils import eapi_request, weapi_headers
|
||||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||||
|
||||
ALBUM_LINK = "https://music.163.com/#/album?id={}"
|
||||
|
||||
|
||||
def album_link(album_id: str) -> str:
|
||||
return ALBUM_LINK.format(album_id)
|
||||
|
||||
|
||||
def convert_song(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""网易云歌曲条目 → SongInfo(与 song_list.filter_list_detail 对齐)。
|
||||
|
||||
这里只在尺寸为正数时声明档位(专辑接口不带 privilege,只能按资源块推断)。
|
||||
"""
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def push(kind: str, size_raw: Any) -> None:
|
||||
size = size_formate(size_raw) if isinstance(size_raw, (int, float)) and size_raw > 0 else None
|
||||
if not size:
|
||||
return
|
||||
types.append({"type": kind, "size": size})
|
||||
_types[kind] = {"size": size}
|
||||
|
||||
push("128k", (item.get("l") or {}).get("size") if isinstance(item.get("l"), dict) else None)
|
||||
push("320k", (item.get("h") or {}).get("size") if isinstance(item.get("h"), dict) else None)
|
||||
push("flac", (item.get("sq") or {}).get("size") if isinstance(item.get("sq"), dict) else None)
|
||||
push("flac24bit", (item.get("hr") or {}).get("size") if isinstance(item.get("hr"), dict) else None)
|
||||
|
||||
album = item.get("al") if isinstance(item.get("al"), dict) else {}
|
||||
if not album:
|
||||
album = item.get("album") if isinstance(item.get("album"), dict) else {}
|
||||
album = album if isinstance(album, dict) else {}
|
||||
return {
|
||||
"singer": format_singer_name(item.get("ar"), "name"),
|
||||
"name": item.get("name") or "",
|
||||
"albumName": album.get("name") 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": {},
|
||||
}
|
||||
|
||||
|
||||
async def fetch_album_detail(album_id: str) -> dict[str, Any]:
|
||||
"""专辑详情 + 全量曲目。失败文案与原文一致(含上游 code)。"""
|
||||
album_key = str(album_id or "").strip()
|
||||
if not album_key:
|
||||
raise RuntimeError("wy 专辑 id 为空")
|
||||
|
||||
resp = await http_fetch(
|
||||
f"https://music.163.com/weapi/v1/album/{album_key}",
|
||||
method="post",
|
||||
headers=weapi_headers(),
|
||||
form=weapi({"csrf_token": ""}),
|
||||
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)})")
|
||||
|
||||
album = body.get("album") or {}
|
||||
artist = album.get("artist") or {}
|
||||
artist_name = artist.get("name") or "、".join(
|
||||
str(a.get("name")) for a in album.get("artists") or [] if isinstance(a, dict) and a.get("name")
|
||||
)
|
||||
songs = [convert_song(song) for song in body.get("songs") or [] if isinstance(song, dict)]
|
||||
return {
|
||||
"source": "wy",
|
||||
"info": {
|
||||
"name": album.get("name") or "",
|
||||
"img": album.get("picUrl") or "",
|
||||
"desc": album.get("description") or album.get("briefDesc") or "",
|
||||
"author": artist_name or "",
|
||||
"albumId": str(album.get("id") or album_key),
|
||||
"publishTime": album.get("publishTime"),
|
||||
"company": album.get("company") or "",
|
||||
},
|
||||
"list": songs,
|
||||
"total": int(album.get("size") or 0) or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_detail(album_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""专辑详情(本地按 page/limit 切片)。"""
|
||||
detail = await fetch_album_detail(album_id)
|
||||
start = max(0, (page - 1) * limit)
|
||||
return {
|
||||
"source": "wy",
|
||||
"info": detail["info"],
|
||||
"list": detail["list"][start : start + limit],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": detail["total"] or len(detail["list"]),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
|
||||
return (await fetch_album_detail(album_id))["list"]
|
||||
|
||||
|
||||
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
"""专辑搜索(eapi cloudsearch pc type=10)。"""
|
||||
resp = await eapi_request(
|
||||
"/api/cloudsearch/pc",
|
||||
{"s": str(keyword or ""), "type": 10, "limit": limit, "total": page == 1, "offset": limit * (page - 1)},
|
||||
)
|
||||
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)})")
|
||||
|
||||
result = body.get("result") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in result.get("albums") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
id_ = str(item.get("id") or "").strip()
|
||||
if not id_:
|
||||
continue
|
||||
artist = item.get("artist") or {}
|
||||
artist_name = artist.get("name") or "、".join(
|
||||
str(a.get("name")) for a in item.get("artists") or [] if isinstance(a, dict) and a.get("name")
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"id": id_,
|
||||
"name": item.get("name") or "",
|
||||
"cover": item.get("picUrl") or "",
|
||||
"trackCount": int(item.get("size") or 0),
|
||||
"creator": artist_name,
|
||||
"description": item.get("description") or item.get("briefDesc") or "",
|
||||
"link": album_link(id_),
|
||||
"type": "album",
|
||||
"company": item.get("company") or "",
|
||||
"publishTime": item.get("publishTime"),
|
||||
"source": "wy",
|
||||
}
|
||||
)
|
||||
total = int(result.get("albumCount") or 0) or len(rows)
|
||||
return {"list": rows, "page": page, "limit": limit, "total": total, "source": "wy"}
|
||||
|
||||
|
||||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||||
"""专辑链接解析(裸数字 id 兜底,上游 ParseAlbum 只接受链接)。"""
|
||||
raw = str(link or "").strip()
|
||||
if raw.isdigit():
|
||||
return await get_album_detail(raw)
|
||||
kind, id_ = parse_netease_link(raw)
|
||||
if kind != "album":
|
||||
raise RuntimeError("无效的网易云专辑链接")
|
||||
return await get_album_detail(id_)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""网易云评论(移植自 sdk/wy/comment.ts)。
|
||||
|
||||
网易云的评论接口是游标式(orderType + cursor + offset),不是页码式。原实现用
|
||||
`cursor_tools` 缓存每首歌最近一次的游标:向后翻页用旧游标 + offset,向前翻页用 orderType=0,
|
||||
重刷同一页则回退到 prevCursor。这里逐行保留该逻辑,否则翻页会跳条。
|
||||
|
||||
注意请求头里的 `Refere` 是原实现的拼写,上游不校验该头,保留以免引入行为差异。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import date_format2
|
||||
from app.sdk.platforms.wy.utils import weapi_headers
|
||||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||||
|
||||
EMOJIS: list[tuple[str, str]] = [
|
||||
("大笑", "😃"),
|
||||
("可爱", "😊"),
|
||||
("憨笑", "☺️"),
|
||||
("色", "😍"),
|
||||
("亲亲", "😙"),
|
||||
("惊恐", "😱"),
|
||||
("流泪", "😭"),
|
||||
("亲", "😚"),
|
||||
("呆", "😳"),
|
||||
("哀伤", "😔"),
|
||||
("呲牙", "😁"),
|
||||
("吐舌", "😝"),
|
||||
("撇嘴", "😒"),
|
||||
("怒", "😡"),
|
||||
("奸笑", "😏"),
|
||||
("汗", "😓"),
|
||||
("痛苦", "😖"),
|
||||
("惶恐", "😰"),
|
||||
("生病", "😨"),
|
||||
("口罩", "😷"),
|
||||
("大哭", "😂"),
|
||||
("晕", "😵"),
|
||||
("发怒", "👿"),
|
||||
("开心", "😄"),
|
||||
("鬼脸", "😜"),
|
||||
("皱眉", "😞"),
|
||||
("流感", "😢"),
|
||||
("爱心", "❤️"),
|
||||
("心碎", "💔"),
|
||||
("钟情", "💘"),
|
||||
("星星", "⭐️"),
|
||||
("生气", "💢"),
|
||||
("便便", "💩"),
|
||||
("强", "👍"),
|
||||
("弱", "👎"),
|
||||
("拜", "🙏"),
|
||||
("牵手", "👫"),
|
||||
("跳舞", "👯♀️"),
|
||||
("禁止", "🙅♀️"),
|
||||
("这边", "💁♀️"),
|
||||
("爱意", "💏"),
|
||||
("示爱", "👩❤️👨"),
|
||||
("嘴唇", "👄"),
|
||||
("狗", "🐶"),
|
||||
("猫", "🐱"),
|
||||
("猪", "🐷"),
|
||||
("兔子", "🐰"),
|
||||
("小鸡", "🐤"),
|
||||
("公鸡", "🐔"),
|
||||
("幽灵", "👻"),
|
||||
("圣诞", "🎅"),
|
||||
("外星", "👽"),
|
||||
("钻石", "💎"),
|
||||
("礼物", "🎁"),
|
||||
("男孩", "👦"),
|
||||
("女孩", "👧"),
|
||||
("蛋糕", "🎂"),
|
||||
("18", "🔞"),
|
||||
("圈", "⭕"),
|
||||
("叉", "❌"),
|
||||
]
|
||||
|
||||
|
||||
def apply_emoji(text: str) -> str:
|
||||
for name, emoji in EMOJIS:
|
||||
text = text.replace(f"[{name}]", emoji)
|
||||
return text
|
||||
|
||||
|
||||
class _CursorTools:
|
||||
"""按歌曲缓存游标。原实现是模块级单例对象,这里用类实例保持同一语义。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def get_cursor(self, id_: str, page: int, limit: int) -> dict[str, Any]:
|
||||
data = self.cache.setdefault(id_, {})
|
||||
order_type: int | None = None
|
||||
cursor: int | None = None
|
||||
offset: int | None = None
|
||||
|
||||
if page == 1:
|
||||
data["page"] = 1
|
||||
cursor = int(time.time() * 1000)
|
||||
data["cursor"] = cursor
|
||||
data["prevCursor"] = cursor
|
||||
order_type = 1
|
||||
offset = 0
|
||||
elif data.get("page"):
|
||||
cursor = data.get("cursor")
|
||||
if page > data["page"]:
|
||||
order_type = 1
|
||||
offset = (page - data["page"] - 1) * limit
|
||||
elif page < data["page"]:
|
||||
order_type = 0
|
||||
offset = (data["page"] - page - 1) * limit
|
||||
else:
|
||||
cursor = data.get("prevCursor")
|
||||
data["cursor"] = cursor
|
||||
offset = data.get("offset")
|
||||
order_type = data.get("orderType")
|
||||
|
||||
return {"orderType": order_type, "cursor": cursor, "offset": offset}
|
||||
|
||||
def set_cursor(self, id_: str, cursor: Any, order_type: Any, offset: Any, page: int) -> None:
|
||||
data = self.cache.setdefault(id_, {})
|
||||
data["prevCursor"] = data.get("cursor")
|
||||
data["cursor"] = cursor
|
||||
data["orderType"] = order_type
|
||||
data["offset"] = offset
|
||||
data["page"] = page
|
||||
|
||||
|
||||
cursor_tools = _CursorTools()
|
||||
|
||||
|
||||
def filter_comment(raw_list: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in raw_list or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
user = item.get("user") or {}
|
||||
raw_time = item.get("time")
|
||||
content = item.get("content")
|
||||
data = {
|
||||
"id": item.get("commentId"),
|
||||
"text": apply_emoji(str(content)) if content else "",
|
||||
"time": raw_time or "",
|
||||
"timeStr": date_format2(raw_time) if raw_time else "",
|
||||
"location": (item.get("ipLocation") or {}).get("location"),
|
||||
"userName": user.get("nickname"),
|
||||
"avatar": user.get("avatarUrl"),
|
||||
"userId": user.get("userId"),
|
||||
"likedCount": item.get("likedCount"),
|
||||
"reply": [],
|
||||
}
|
||||
|
||||
replied = item.get("beReplied") or []
|
||||
reply_data = replied[0] if replied else None
|
||||
if not isinstance(reply_data, dict):
|
||||
rows.append(data)
|
||||
continue
|
||||
reply_user = reply_data.get("user") or {}
|
||||
reply_content = reply_data.get("content")
|
||||
rows.append(
|
||||
{
|
||||
"id": item.get("commentId"),
|
||||
"rootId": reply_data.get("beRepliedCommentId"),
|
||||
"text": apply_emoji(str(reply_content)) if reply_content else "",
|
||||
"time": item.get("time"),
|
||||
"timeStr": None,
|
||||
"location": (reply_data.get("ipLocation") or {}).get("location"),
|
||||
"userName": reply_user.get("nickname"),
|
||||
"avatar": reply_user.get("avatarUrl"),
|
||||
"userId": reply_user.get("userId"),
|
||||
"likedCount": None,
|
||||
"reply": [data],
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _max_page(total: int, limit: int) -> int:
|
||||
return math.ceil(total / limit) if limit else 1
|
||||
|
||||
|
||||
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""歌曲评论。失败文案与原文一致:获取评论失败。"""
|
||||
song_mid = str(song_info.get("songmid"))
|
||||
rid = f"R_SO_4_{song_mid}"
|
||||
cursor_info = cursor_tools.get_cursor(song_mid, page, limit)
|
||||
|
||||
resp = await http_fetch(
|
||||
"https://music.163.com/weapi/comment/resource/comments/get",
|
||||
method="post",
|
||||
headers=weapi_headers(with_cookie=False),
|
||||
form=weapi(
|
||||
{
|
||||
"cursor": cursor_info["cursor"],
|
||||
"offset": cursor_info["offset"],
|
||||
"orderType": cursor_info["orderType"],
|
||||
"pageNo": page,
|
||||
"pageSize": limit,
|
||||
"rid": rid,
|
||||
"threadId": rid,
|
||||
}
|
||||
),
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 200:
|
||||
raise RuntimeError("获取评论失败")
|
||||
|
||||
data = body.get("data") or {}
|
||||
cursor_tools.set_cursor(song_mid, data.get("cursor"), cursor_info["orderType"], cursor_info["offset"], page)
|
||||
total = int(data.get("totalCount") or 0)
|
||||
return {
|
||||
"source": "wy",
|
||||
"comments": filter_comment(data.get("comments")),
|
||||
"total": data.get("totalCount"),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"maxPage": _max_page(total, limit) or 1,
|
||||
}
|
||||
|
||||
|
||||
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
"""热门评论。失败文案与原文一致:获取热门评论失败。"""
|
||||
song_mid = str(song_info.get("songmid"))
|
||||
rid = f"R_SO_4_{song_mid}"
|
||||
page = page - 1
|
||||
|
||||
resp = await http_fetch(
|
||||
f"https://music.163.com/weapi/v1/resource/hotcomments/{rid}",
|
||||
method="post",
|
||||
headers=weapi_headers(with_cookie=False),
|
||||
form=weapi(
|
||||
{
|
||||
"rid": rid,
|
||||
"limit": limit,
|
||||
"offset": limit * page,
|
||||
"beforeTime": str(int(time.time() * 1000)),
|
||||
}
|
||||
),
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 200:
|
||||
raise RuntimeError("获取热门评论失败")
|
||||
|
||||
total = int(body.get("total") or 0)
|
||||
return {
|
||||
"source": "wy",
|
||||
"comments": filter_comment(body.get("hotComments")),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"maxPage": _max_page(total, limit) or 1,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""网易云热搜词(移植自 sdk/wy/hotSearch.ts)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.wy.utils import eapi_request
|
||||
|
||||
|
||||
def filter_list(raw_list: Any) -> list[str]:
|
||||
return [str(item.get("searchWord")) for item in raw_list or [] if isinstance(item, dict) and item.get("searchWord")]
|
||||
|
||||
|
||||
async def get_list(retry_num: int = 0) -> dict[str, Any]:
|
||||
"""热搜词列表。失败文案与原文一致:获取热搜词失败。"""
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
resp = await eapi_request("/api/search/chart/detail", {"id": "HOT_SEARCH_SONG#@#"})
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 200:
|
||||
raise RuntimeError("获取热搜词失败")
|
||||
|
||||
data = body.get("data") or {}
|
||||
return {"source": "wy", "list": filter_list(data.get("itemList"))}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""网易云榜单(移植自 sdk/wy/leaderboard.ts)。
|
||||
|
||||
榜单列表是硬编码的 top_list(原实现的 get_boards 直接返回它,不再请求 weapi/toplist);
|
||||
详情走 weapi/v3/playlist/detail 拿 trackIds,再用 song/detail 批量补全曲目。
|
||||
|
||||
id 必须是 `wy__<bangid>` 形态:路由 `_board_handler` 会剥掉 `wy__` 前缀得到裸 bangid,
|
||||
前端拿到的 id 也只有带前缀时才和详情接口闭环。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.wy import music_detail
|
||||
from app.sdk.platforms.wy.utils import weapi_headers
|
||||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||||
|
||||
LIMIT = 100000
|
||||
|
||||
TOP_LIST: list[dict[str, str]] = [
|
||||
{"id": "wy__19723756", "name": "飙升榜", "bangid": "19723756"},
|
||||
{"id": "wy__3779629", "name": "新歌榜", "bangid": "3779629"},
|
||||
{"id": "wy__2884035", "name": "原创榜", "bangid": "2884035"},
|
||||
{"id": "wy__3778678", "name": "热歌榜", "bangid": "3778678"},
|
||||
{"id": "wy__991319590", "name": "说唱榜", "bangid": "991319590"},
|
||||
{"id": "wy__71384707", "name": "古典榜", "bangid": "71384707"},
|
||||
{"id": "wy__1978921795", "name": "电音榜", "bangid": "1978921795"},
|
||||
{"id": "wy__5453912201", "name": "黑胶VIP爱听榜", "bangid": "5453912201"},
|
||||
{"id": "wy__71385702", "name": "ACG榜", "bangid": "71385702"},
|
||||
{"id": "wy__745956260", "name": "韩语榜", "bangid": "745956260"},
|
||||
{"id": "wy__10520166", "name": "国电榜", "bangid": "10520166"},
|
||||
{"id": "wy__180106", "name": "UK排行榜周榜", "bangid": "180106"},
|
||||
{"id": "wy__60198", "name": "美国Billboard榜", "bangid": "60198"},
|
||||
{"id": "wy__3812895", "name": "Beatport全球电子舞曲榜", "bangid": "3812895"},
|
||||
{"id": "wy__21845217", "name": "KTV唛榜", "bangid": "21845217"},
|
||||
{"id": "wy__60131", "name": "日本Oricon榜", "bangid": "60131"},
|
||||
{"id": "wy__2809513713", "name": "欧美热歌榜", "bangid": "2809513713"},
|
||||
{"id": "wy__2809577409", "name": "欧美新歌榜", "bangid": "2809577409"},
|
||||
{"id": "wy__27135204", "name": "法国 NRJ Vos Hits 周榜", "bangid": "27135204"},
|
||||
{"id": "wy__3001835560", "name": "ACG动画榜", "bangid": "3001835560"},
|
||||
{"id": "wy__3001795926", "name": "ACG游戏榜", "bangid": "3001795926"},
|
||||
{"id": "wy__3001890046", "name": "ACG VOCALOID榜", "bangid": "3001890046"},
|
||||
{"id": "wy__3112516681", "name": "中国新乡村音乐排行榜", "bangid": "3112516681"},
|
||||
{"id": "wy__5059644681", "name": "日语榜", "bangid": "5059644681"},
|
||||
{"id": "wy__5059633707", "name": "摇滚榜", "bangid": "5059633707"},
|
||||
{"id": "wy__5059642708", "name": "国风榜", "bangid": "5059642708"},
|
||||
{"id": "wy__5338990334", "name": "潜力爆款榜", "bangid": "5338990334"},
|
||||
{"id": "wy__5059661515", "name": "民谣榜", "bangid": "5059661515"},
|
||||
{"id": "wy__6688069460", "name": "听歌识曲榜", "bangid": "6688069460"},
|
||||
{"id": "wy__6723173524", "name": "网络热歌榜", "bangid": "6723173524"},
|
||||
{"id": "wy__6732051320", "name": "俄语榜", "bangid": "6732051320"},
|
||||
{"id": "wy__6732014811", "name": "越南语榜", "bangid": "6732014811"},
|
||||
{"id": "wy__6886768100", "name": "中文DJ榜", "bangid": "6886768100"},
|
||||
{"id": "wy__6939992364", "name": "俄罗斯top hit流行音乐榜", "bangid": "6939992364"},
|
||||
{"id": "wy__7095271308", "name": "泰语榜", "bangid": "7095271308"},
|
||||
{"id": "wy__7356827205", "name": "BEAT排行榜", "bangid": "7356827205"},
|
||||
{"id": "wy__7325478166", "name": "编辑推荐榜VOL.44 天才女子摇滚乐队boygenius剖白卑微心迹", "bangid": "7325478166"},
|
||||
{"id": "wy__7603212484", "name": "LOOK直播歌曲榜", "bangid": "7603212484"},
|
||||
{"id": "wy__7775163417", "name": "赏音榜", "bangid": "7775163417"},
|
||||
{"id": "wy__7785123708", "name": "黑胶VIP新歌榜", "bangid": "7785123708"},
|
||||
{"id": "wy__7785066739", "name": "黑胶VIP热歌榜", "bangid": "7785066739"},
|
||||
{"id": "wy__7785091694", "name": "黑胶VIP爱搜榜", "bangid": "7785091694"},
|
||||
]
|
||||
|
||||
|
||||
def filter_boards_data(raw_list: Any) -> list[dict[str, str]]:
|
||||
"""weapi/toplist 响应 → 榜单列表(原实现的 filterBoardsData;当前 get_boards 不走它)。"""
|
||||
return [
|
||||
{"id": f"wy__{board['id']}", "name": str(board.get("name")), "bangid": str(board["id"])}
|
||||
for board in raw_list or []
|
||||
if isinstance(board, dict) and board.get("id") is not None
|
||||
]
|
||||
|
||||
|
||||
async def get_boards() -> dict[str, Any]:
|
||||
"""榜单列表。原实现直接返回硬编码 top_list,不请求上游。"""
|
||||
return {"list": TOP_LIST, "source": "wy"}
|
||||
|
||||
|
||||
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""榜单详情。失败递归重试最多 6 次,超出抛 try max num(文案与原文一致)。"""
|
||||
retry_num += 1
|
||||
if retry_num > 6:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
resp = await http_fetch(
|
||||
"https://music.163.com/weapi/v3/playlist/detail",
|
||||
method="post",
|
||||
headers=weapi_headers(with_cookie=False),
|
||||
form=weapi({"id": bangid, "n": LIMIT, "p": 1}),
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 200:
|
||||
# 让出事件循环:上游持续 5xx 时递归重试不应霸占单线程事件循环
|
||||
await asyncio.sleep(0)
|
||||
return await get_list(bangid, page, retry_num)
|
||||
|
||||
playlist = body.get("playlist") or {}
|
||||
ids = [track.get("id") for track in playlist.get("trackIds") or [] if isinstance(track, dict)]
|
||||
try:
|
||||
detail = await music_detail.get_list(ids)
|
||||
except Exception as err:
|
||||
print(f"[wy] 榜单歌曲详情获取失败将重试({retry_num + 1}): {err}")
|
||||
if str(err) == "try max num":
|
||||
raise
|
||||
await asyncio.sleep(0)
|
||||
return await get_list(bangid, page, retry_num)
|
||||
|
||||
rows = detail["list"]
|
||||
return {"total": len(rows), "list": rows, "limit": LIMIT, "page": page, "source": "wy"}
|
||||
|
||||
|
||||
def get_detail_page_url(bangid: str) -> str:
|
||||
bare = bangid.replace("wy__", "") if isinstance(bangid, str) else bangid
|
||||
return f"https://music.163.com/#/discover/toplist?id={bare}"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""网易云链接解析(移植自 sdk/wy/link.ts,上游 music-lib/netease/netease.go 的 parseNeteaseLink)。
|
||||
|
||||
支持:
|
||||
歌曲 https://music.163.com/#/song?id=29732995 /song?id= / /song/29732995
|
||||
专辑 https://music.163.com/#/album?id=32311 /album?id= / /album/32311
|
||||
歌单 https://music.163.com/#/playlist?id=7507256008 /playlist?id= / /playlist/<id>/<n>/...
|
||||
裸数字 id 由各模块自行兜底(上游 Parse 不接受裸 id)。
|
||||
|
||||
顺序上依次尝试原链接、path+query、hash 片段 —— 网页版把真实路径放在 # 后面,
|
||||
因此必须逐候选尝试,只看 pathname 会漏掉 `#/song?id=` 这类主流形态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import parse_qs, urljoin, urlparse
|
||||
|
||||
NeteaseLinkKind = Literal["song", "album", "playlist"]
|
||||
|
||||
BASE = "https://music.163.com/"
|
||||
_KINDS = ("song", "album", "playlist")
|
||||
_DIGITS = re.compile(r"^\d+$")
|
||||
|
||||
|
||||
def _is_digits(value: str) -> bool:
|
||||
return value != "" and _DIGITS.match(value) is not None
|
||||
|
||||
|
||||
def _parse_candidate(candidate: str) -> tuple[str, str]:
|
||||
"""解析单个候选串(JS 需给相对候选补 base;Go 的 url.Parse 直接接受相对路径)。"""
|
||||
try:
|
||||
parsed = urlparse(urljoin(BASE, candidate))
|
||||
except ValueError:
|
||||
return "", ""
|
||||
|
||||
kind = ""
|
||||
segments = [seg for seg in parsed.path.lower().split("/") if seg]
|
||||
# 取最后一个出现的类型段:/playlist/xxx 之类多余段不影响判断
|
||||
for segment in segments:
|
||||
if segment in _KINDS:
|
||||
kind = segment
|
||||
|
||||
id_ = parse_qs(parsed.query).get("id", [""])[0]
|
||||
if not _is_digits(id_):
|
||||
id_ = ""
|
||||
|
||||
if not kind and len(segments) >= 2:
|
||||
last, prev = segments[-1], segments[-2]
|
||||
if _is_digits(last) and prev in _KINDS:
|
||||
kind, id_ = prev, last
|
||||
|
||||
if not kind or not id_:
|
||||
return "", ""
|
||||
return kind, id_
|
||||
|
||||
|
||||
def parse_netease_link(link: Any) -> tuple[NeteaseLinkKind, str]:
|
||||
"""链接 → (类型, id)。无法识别时抛 ValueError(文案与原文一致)。"""
|
||||
raw = str(link or "").strip()
|
||||
candidates = [raw]
|
||||
|
||||
try:
|
||||
parsed = urlparse(urljoin(BASE, raw))
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if parsed is not None:
|
||||
if parsed.path and parsed.path != "/":
|
||||
candidates.append(parsed.path + (f"?{parsed.query}" if parsed.query else ""))
|
||||
fragment = re.sub(r"^#!?", "", parsed.fragment).strip()
|
||||
if fragment:
|
||||
candidates.append(fragment)
|
||||
|
||||
for candidate in candidates:
|
||||
kind, id_ = _parse_candidate(candidate)
|
||||
if kind and id_:
|
||||
return kind, id_ # type: ignore[return-value]
|
||||
raise ValueError("无效的网易云链接")
|
||||
|
||||
|
||||
def bare_id(link: Any) -> str:
|
||||
"""裸 id 兜底:非 URL 且全数字时视为对应类型的 id。"""
|
||||
raw = str(link or "").strip()
|
||||
return raw if _DIGITS.match(raw) else ""
|
||||
@@ -0,0 +1,247 @@
|
||||
"""网易云歌词(移植自 sdk/wy/lyric.ts,参考 Binaryify/NeteaseCloudMusicApi 的 module/lyric_new.js)。
|
||||
|
||||
上游返回 yrc(逐字)+ lrc(逐行)+ 各语种翻译/rlrc。逐字歌词的时间标签是
|
||||
`[起始ms,时长ms](词起始ms,词时长ms,0)词` 形态,需要转换成标准 LRC + 富文本 `<偏移,时长>` 标签。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME
|
||||
from app.sdk.platforms.wy.utils.crypto import eapi
|
||||
|
||||
RX_INFO = re.compile(r'^{"')
|
||||
RX_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
|
||||
RX_WORD_TIME = re.compile(r"\(\d+,\d+,\d+\)")
|
||||
RX_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+,\d+\))")
|
||||
RX_WORD_ITEM = re.compile(r"\((\d+),(\d+),\d+\)")
|
||||
|
||||
LYRIC_HOST = "https://interface3.music.163.com/eapi/song/lyric/v1"
|
||||
|
||||
|
||||
def ms_format(time_ms: Any) -> str:
|
||||
"""毫秒 → `[mm:ss.SSS]`。非数字返回空串(原实现用 Number.isNaN 判定)。"""
|
||||
try:
|
||||
total = int(float(time_ms))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
if total < 0:
|
||||
return ""
|
||||
ms = total % 1000
|
||||
seconds = total // 1000
|
||||
# 与原实现一致:毫秒不补齐(JS 的 `timeMs % 1000` 直接插值)
|
||||
return f"[{seconds // 60:02d}:{seconds % 60:02d}.{ms}]"
|
||||
|
||||
|
||||
def parse_lyric(lines: list[str]) -> dict[str, str]:
|
||||
yrc_lines: list[str] = []
|
||||
lrc_lines: list[str] = []
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
match = RX_LINE_TIME.match(line)
|
||||
if not match:
|
||||
if line.startswith("[offset"):
|
||||
yrc_lines.append(line)
|
||||
lrc_lines.append(line)
|
||||
continue
|
||||
|
||||
start_ms = int(match.group(1))
|
||||
start_str = ms_format(start_ms)
|
||||
if not start_str:
|
||||
continue
|
||||
|
||||
words = RX_LINE_TIME.sub("", line)
|
||||
lrc_lines.append(start_str + RX_WORD_TIME_ALL.sub("", words))
|
||||
|
||||
word_times = RX_WORD_TIME_ALL.findall(words)
|
||||
if not word_times:
|
||||
continue
|
||||
tags = []
|
||||
for chunk in word_times:
|
||||
item = RX_WORD_ITEM.search(chunk)
|
||||
if not item:
|
||||
continue
|
||||
tags.append(f"<{max(int(item.group(1)) - start_ms, 0)},{item.group(2)}>")
|
||||
word_arr = RX_WORD_TIME.split(words)[1:] # 首段是第一个时间标签前的内容
|
||||
new_words = "".join(
|
||||
f"{tag}{word_arr[index] if index < len(word_arr) else ''}" for index, tag in enumerate(tags)
|
||||
)
|
||||
yrc_lines.append(f"{start_str}{new_words}")
|
||||
|
||||
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
|
||||
|
||||
|
||||
def parse_header_info(text: Any) -> list[str] | None:
|
||||
"""清洗歌词文本:去掉 \\r,把 `{"t":ms,"c":[{"tx":...}]}` 形式的逐字行转成标准 LRC 行。"""
|
||||
if text is None:
|
||||
return None
|
||||
raw = str(text).strip().replace("\r", "")
|
||||
if not raw:
|
||||
return None
|
||||
out: list[str] = []
|
||||
for line in raw.split("\n"):
|
||||
if not RX_INFO.match(line):
|
||||
out.append(line)
|
||||
continue
|
||||
try:
|
||||
info = json.loads(line)
|
||||
except (ValueError, TypeError):
|
||||
out.append("")
|
||||
continue
|
||||
tag = ms_format(info.get("t"))
|
||||
if not tag:
|
||||
out.append("")
|
||||
continue
|
||||
out.append(tag + "".join(str(chunk.get("tx", "")) for chunk in info.get("c") or []))
|
||||
return out
|
||||
|
||||
|
||||
def get_intv(interval: Any) -> int:
|
||||
"""`mm:ss.SSS`(或 `hh:mm:ss.SSS`)→ 毫秒。"""
|
||||
if not interval:
|
||||
return 0
|
||||
text = str(interval)
|
||||
if "." not in text:
|
||||
text += ".0"
|
||||
parts = re.split(r"[:.]", text)
|
||||
while len(parts) < 3:
|
||||
parts.insert(0, "0")
|
||||
minute, second, milli = parts[0], parts[1], parts[2]
|
||||
|
||||
def to_int(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return to_int(minute) * 3_600_000 + to_int(second) * 1000 + to_int(milli)
|
||||
|
||||
|
||||
def fix_time_tag(lrc: str, target_lrc: str) -> str:
|
||||
"""把 target 的文本按时间接近(<100ms)对齐到 lrc 的时间标签上。"""
|
||||
rx_time = re.compile(r"^\[([\d:.]+)\]")
|
||||
lrc_lines = lrc.split("\n")
|
||||
new_lrc: list[str] = []
|
||||
temp: list[str] = []
|
||||
|
||||
for line in target_lrc.split("\n"):
|
||||
match = rx_time.match(line)
|
||||
if not match:
|
||||
continue
|
||||
words = rx_time.sub("", line)
|
||||
if not words.strip():
|
||||
continue
|
||||
t1 = get_intv(match.group(1))
|
||||
|
||||
while lrc_lines:
|
||||
lrc_line = lrc_lines.pop(0)
|
||||
lrc_match = rx_time.match(lrc_line)
|
||||
if not lrc_match:
|
||||
continue
|
||||
t2 = get_intv(lrc_match.group(1))
|
||||
if abs(t1 - t2) < 100:
|
||||
merged = rx_time.sub(lrc_match.group(0), line).strip()
|
||||
if not merged:
|
||||
continue
|
||||
new_lrc.append(merged)
|
||||
break
|
||||
temp.append(lrc_line)
|
||||
lrc_lines = [*temp, *lrc_lines]
|
||||
temp = []
|
||||
|
||||
return "\n".join(new_lrc)
|
||||
|
||||
|
||||
def parse(y_lrc: Any, yt_lrc: Any, yr_lrc: Any, lrc: Any, tlrc: Any, rlrc: Any) -> dict[str, str]:
|
||||
"""优先用逐字歌词(yrc)合成,缺失时退回普通 LRC。"""
|
||||
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
|
||||
if y_lrc:
|
||||
lines = parse_header_info(y_lrc)
|
||||
if lines:
|
||||
result = parse_lyric(lines)
|
||||
if yt_lrc:
|
||||
t_lines = parse_header_info(yt_lrc)
|
||||
if t_lines:
|
||||
info["tlyric"] = fix_time_tag(result["lyric"], "\n".join(t_lines))
|
||||
if yr_lrc:
|
||||
r_lines = parse_header_info(yr_lrc)
|
||||
if r_lines:
|
||||
info["rlyric"] = fix_time_tag(result["lyric"], "\n".join(r_lines))
|
||||
|
||||
headers = "\n".join(line for line in lines if re.match(r"^\[[\d:.]+\]", line))
|
||||
info["lyric"] = f"{headers}\n{result['lyric']}"
|
||||
info["yrc"] = result["yrc"]
|
||||
return info
|
||||
|
||||
for key, value in (("lyric", lrc), ("tlyric", tlrc), ("rlyric", rlrc)):
|
||||
if not value:
|
||||
continue
|
||||
parsed_lines = parse_header_info(value)
|
||||
if parsed_lines:
|
||||
info[key] = "\n".join(parsed_lines)
|
||||
return info
|
||||
|
||||
|
||||
def fix_time_label(lrc: Any, tlrc: Any = None, romalrc: Any = None) -> tuple[Any, Any, Any]:
|
||||
"""修正 `[mm:ss:SS]` 这类把毫秒写成第二冒号的错误时间标签(社区通行做法)。"""
|
||||
if not lrc:
|
||||
return lrc, tlrc, romalrc
|
||||
text = str(lrc)
|
||||
new_lrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", text)
|
||||
new_tlrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", tlrc) if isinstance(tlrc, str) else tlrc
|
||||
if new_lrc != text or new_tlrc != tlrc:
|
||||
lrc, tlrc = new_lrc, new_tlrc
|
||||
if isinstance(romalrc, str):
|
||||
romalrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2,3})\]", r"[\1.\2]", romalrc)
|
||||
romalrc = re.sub(r"\[(\d{2}:\d{2}\.\d{2})0\]", r"[\1]", romalrc)
|
||||
return lrc, tlrc, romalrc
|
||||
|
||||
|
||||
def _pick_lyric(block: Any) -> Any:
|
||||
return block.get("lyric") if isinstance(block, dict) else None
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
"""取歌词。原文失败文案:Get lyric failed。"""
|
||||
resp = await http_fetch(
|
||||
LYRIC_HOST,
|
||||
method="post",
|
||||
headers={"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"},
|
||||
form=eapi(
|
||||
"/api/song/lyric/v1",
|
||||
{
|
||||
"id": song_info.get("songmid"),
|
||||
"cp": False,
|
||||
"tv": 0,
|
||||
"lv": 0,
|
||||
"rv": 0,
|
||||
"kv": 0,
|
||||
"yv": 0,
|
||||
"ytv": 0,
|
||||
"yrv": 0,
|
||||
},
|
||||
),
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("code") != 200 or not _pick_lyric(body.get("lrc")):
|
||||
raise RuntimeError("Get lyric failed")
|
||||
|
||||
lrc, tlrc, romalrc = fix_time_label(
|
||||
_pick_lyric(body.get("lrc")), _pick_lyric(body.get("tlyric")), _pick_lyric(body.get("romalrc"))
|
||||
)
|
||||
info = parse(
|
||||
_pick_lyric(body.get("yrc")),
|
||||
_pick_lyric(body.get("ytlrc")),
|
||||
_pick_lyric(body.get("yromalrc")),
|
||||
lrc,
|
||||
tlrc,
|
||||
romalrc,
|
||||
)
|
||||
if not info["lyric"]:
|
||||
raise RuntimeError("Get lyric failed")
|
||||
return info
|
||||
@@ -0,0 +1,141 @@
|
||||
"""歌曲详情与音质档位换算(移植自 sdk/wy/musicDetail.ts)。
|
||||
|
||||
上游 weapi/v3/song/detail 返回 songs + privileges,音质档位由 privilege.maxbr/maxBrLevel 决定,
|
||||
档位声明顺序与 TS 版一致(flac24bit 先 push,switch 里依次追加,最后 reverse)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import format_play_time, size_formate
|
||||
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers
|
||||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||||
|
||||
|
||||
def _size_of(block: Any) -> str | None:
|
||||
"""{size: N} → '1.23 MB';缺块返回 None(与 TS 的 `item.x ? sizeFormate(item.x.size) : null` 一致)。"""
|
||||
if isinstance(block, dict) and block.get("size"):
|
||||
return size_formate(block["size"])
|
||||
return None
|
||||
|
||||
|
||||
def build_types(item: dict[str, Any], privilege: dict[str, Any], *, flac_size: bool = True) -> tuple[list[dict], dict]:
|
||||
"""按 privilege 生成 types/_types。`flac_size=False` 用于歌单详情(原实现那里 flac 的 size 恒为 null)。"""
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
|
||||
if privilege.get("maxBrLevel") == "hires":
|
||||
size = _size_of(item.get("hr"))
|
||||
types.append({"type": "flac24bit", "size": size})
|
||||
_types["flac24bit"] = {"size": size}
|
||||
|
||||
maxbr = privilege.get("maxbr")
|
||||
# 无 break 的 switch 直落是有意为之:高码率同时声明向下兼容的档位
|
||||
if maxbr in (999000, 320000, 192000, 128000):
|
||||
if maxbr == 999000:
|
||||
size = _size_of(item.get("sq")) if flac_size else None
|
||||
types.append({"type": "flac", "size": size})
|
||||
_types["flac"] = {"size": size}
|
||||
if maxbr in (999000, 320000):
|
||||
size = _size_of(item.get("h"))
|
||||
types.append({"type": "320k", "size": size})
|
||||
_types["320k"] = {"size": size}
|
||||
size = _size_of(item.get("l"))
|
||||
types.append({"type": "128k", "size": size})
|
||||
_types["128k"] = {"size": size}
|
||||
|
||||
types.reverse()
|
||||
return types, _types
|
||||
|
||||
|
||||
def _match_privilege(privileges: list[dict[str, Any]], item: dict[str, Any], index: int) -> dict[str, Any] | None:
|
||||
"""优先按下标取,id 不符时按 id 查;都找不到返回 None(原实现会跳过该曲目)。"""
|
||||
candidate = privileges[index] if index < len(privileges) else None
|
||||
if isinstance(candidate, dict) and candidate.get("id") == item.get("id"):
|
||||
return candidate
|
||||
for privilege in privileges:
|
||||
if isinstance(privilege, dict) and privilege.get("id") == item.get("id"):
|
||||
return privilege
|
||||
return None
|
||||
|
||||
|
||||
def get_singer(singers: Any) -> str:
|
||||
return "、".join(str(singer.get("name")) for singer in singers or [] if isinstance(singer, dict))
|
||||
|
||||
|
||||
def filter_list(songs: list[dict[str, Any]], privileges: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""song/detail 响应 → SongInfo 列表。"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for index, item in enumerate(songs):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
privilege = _match_privilege(privileges, item, index)
|
||||
if privilege is None:
|
||||
continue
|
||||
types, _types = build_types(item, privilege)
|
||||
album = item.get("al") or {}
|
||||
pc = item.get("pc")
|
||||
if pc:
|
||||
# 云盘/翻唱曲目走 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": get_singer(item.get("ar")),
|
||||
"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(ids: list[Any] | None = None, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""按 id 批量取歌曲详情(weapi/v3/song/detail)。"""
|
||||
ids = ids or []
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
form = weapi(
|
||||
{
|
||||
"c": "[" + ",".join('{"id":' + str(id_) + "}" for id_ in ids) + "]",
|
||||
"ids": "[" + ",".join(str(id_) for id_ in ids) + "]",
|
||||
}
|
||||
)
|
||||
resp = await http_fetch(
|
||||
"https://music.163.com/weapi/v3/song/detail",
|
||||
method="post",
|
||||
headers={**weapi_headers(with_cookie=False), "User-Agent": UA_LINUX_CHROME},
|
||||
form=form,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 200:
|
||||
raise RuntimeError("获取歌曲详情失败")
|
||||
return {"source": "wy", "list": filter_list(body.get("songs") or [], body.get("privileges") or [])}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""网易云歌曲信息(移植自 sdk/wy/musicInfo.ts,走 weapi/v3/song/detail 取原始 song 对象)。
|
||||
|
||||
与 music_detail.py 的差别:这里返回上游原始 `songs[0]`(含 al/ar/hr/sq 等),供封面等场景直接取值。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers
|
||||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||||
|
||||
|
||||
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""取原始 song 对象。失败文案与原文一致:获取歌曲信息失败。"""
|
||||
song_mid = song_info.get("songmid")
|
||||
resp = await http_fetch(
|
||||
"https://music.163.com/weapi/v3/song/detail",
|
||||
method="post",
|
||||
headers={
|
||||
**weapi_headers(f"https://music.163.com/song?id={song_mid}", with_cookie=False),
|
||||
"User-Agent": UA_LINUX_CHROME,
|
||||
},
|
||||
form=weapi({"c": f'[{{"id":{song_mid}}}]', "ids": f"[{song_mid}]"}),
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
songs = body.get("songs") or []
|
||||
if body.get("code") != 200 or not songs:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
song = songs[0]
|
||||
if not isinstance(song, dict):
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
return song
|
||||
@@ -0,0 +1,125 @@
|
||||
"""网易云搜索(移植自 sdk/wy/musicSearch.ts)。
|
||||
|
||||
走 eapi 通道 /api/search/song/list/page(cloudsearch pc 已被注释掉,保留原样)。
|
||||
响应结构是 resources[].baseInfo.simpleSongData,与 song/detail 的扁平结构不同,
|
||||
因此音质解析单独一份(privilege 内嵌在 simpleSongData 里)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import format_play_time, size_formate
|
||||
from app.sdk.platforms.wy.utils import eapi_request
|
||||
|
||||
DEFAULT_LIMIT = 30
|
||||
|
||||
# 模块级状态与原 TS 一致(路由不读这些字段,但 allPage/total 由它们推导)
|
||||
_state: dict[str, Any] = {"total": 0, "page": 0, "all_page": 1, "limit": DEFAULT_LIMIT}
|
||||
|
||||
|
||||
def get_singer(singers: Any) -> str:
|
||||
return "、".join(str(singer.get("name")) for singer in singers or [] if isinstance(singer, dict))
|
||||
|
||||
|
||||
def _size_of(block: Any) -> str | None:
|
||||
if isinstance(block, dict) and block.get("size"):
|
||||
return size_formate(block["size"])
|
||||
return None
|
||||
|
||||
|
||||
def handle_result(raw_list: Any) -> list[dict[str, Any]]:
|
||||
if not raw_list:
|
||||
return []
|
||||
rows: list[dict[str, Any]] = []
|
||||
for raw in raw_list:
|
||||
item = ((raw or {}).get("baseInfo") or {}).get("simpleSongData") or {}
|
||||
privilege = item.get("privilege") or {}
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
|
||||
if privilege.get("maxBrLevel") == "hires":
|
||||
size = _size_of(item.get("hr"))
|
||||
types.append({"type": "flac24bit", "size": size})
|
||||
_types["flac24bit"] = {"size": size}
|
||||
|
||||
maxbr = privilege.get("maxbr")
|
||||
# 无 break 的 switch 直落:高码率同时声明向下兼容档位
|
||||
if maxbr in (999000, 320000, 192000, 128000):
|
||||
if maxbr == 999000:
|
||||
size = _size_of(item.get("sq"))
|
||||
types.append({"type": "flac", "size": size})
|
||||
_types["flac"] = {"size": size}
|
||||
if maxbr in (999000, 320000):
|
||||
size = _size_of(item.get("h"))
|
||||
types.append({"type": "320k", "size": size})
|
||||
_types["320k"] = {"size": size}
|
||||
size = _size_of(item.get("l"))
|
||||
types.append({"type": "128k", "size": size})
|
||||
_types["128k"] = {"size": size}
|
||||
|
||||
types.reverse()
|
||||
|
||||
album = item.get("al") or {}
|
||||
rows.append(
|
||||
{
|
||||
"singer": get_singer(item.get("ar")),
|
||||
"name": item.get("name"),
|
||||
"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,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def _music_search(keyword: str, page: int, limit: int) -> Any:
|
||||
resp = await eapi_request(
|
||||
"/api/search/song/list/page",
|
||||
{
|
||||
"keyword": keyword,
|
||||
"needCorrect": "1",
|
||||
"channel": "typing",
|
||||
"offset": limit * (page - 1),
|
||||
"scene": "normal",
|
||||
"total": page == 1,
|
||||
"limit": limit,
|
||||
},
|
||||
)
|
||||
return resp.body
|
||||
|
||||
|
||||
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""搜索单曲。失败重试上限 3 次(文案与原文一致:try max num)。"""
|
||||
retry_num += 1
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("try max num")
|
||||
if not limit:
|
||||
limit = DEFAULT_LIMIT
|
||||
|
||||
result = await _music_search(keyword, page, limit)
|
||||
if not isinstance(result, dict) or result.get("code") != 200:
|
||||
return await search(keyword, page, limit, retry_num)
|
||||
|
||||
data = result.get("data") or {}
|
||||
rows = handle_result(data.get("resources") or [])
|
||||
|
||||
_state["total"] = data.get("totalCount") or 0
|
||||
_state["page"] = page
|
||||
_state["all_page"] = -(-int(_state["total"]) // DEFAULT_LIMIT) # ceil 除法
|
||||
_state["limit"] = DEFAULT_LIMIT
|
||||
|
||||
return {
|
||||
"list": rows,
|
||||
"allPage": _state["all_page"],
|
||||
"limit": DEFAULT_LIMIT,
|
||||
"total": _state["total"],
|
||||
"source": "wy",
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""网易云链接解析(移植自 sdk/wy/parse.ts,上游 music-lib/netease/song.go 的 Parse)。
|
||||
|
||||
歌曲链接形态:
|
||||
https://music.163.com/#/song?id=29732995
|
||||
https://music.163.com/song?id=29732995
|
||||
https://music.163.com/song/29732995
|
||||
裸数字 id 也接受(补充便利;上游仅接受链接)。
|
||||
直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
|
||||
歌单/专辑链接抛清晰错误,提示改用 parse_playlist / parse_album。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.wy.album import convert_song, parse_album_link
|
||||
from app.sdk.platforms.wy.link import parse_netease_link
|
||||
from app.sdk.platforms.wy.playlist import parse_playlist_link
|
||||
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers
|
||||
from app.sdk.platforms.wy.utils.crypto import weapi
|
||||
|
||||
|
||||
async def fetch_song_by_id(song_id: str) -> dict[str, Any]:
|
||||
"""上游 fetchSongsBatch 的单曲版(weapi/v3/song/detail)。"""
|
||||
resp = await http_fetch(
|
||||
"https://music.163.com/weapi/v3/song/detail",
|
||||
method="post",
|
||||
headers={**weapi_headers(), "User-Agent": UA_LINUX_CHROME},
|
||||
form=weapi({"c": f'[{{"id":{song_id}}}]', "ids": f"[{song_id}]"}),
|
||||
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)})")
|
||||
songs = body.get("songs") or []
|
||||
item = songs[0] if songs else None
|
||||
if not isinstance(item, dict) or not item.get("id"):
|
||||
raise RuntimeError("netease song not found")
|
||||
return convert_song(item)
|
||||
|
||||
|
||||
async def parse_song(link: str) -> dict[str, Any]:
|
||||
"""歌曲链接 → SongInfo(尽力附带直链 url/type)。"""
|
||||
raw = str(link or "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError("无效的网易云歌曲链接")
|
||||
|
||||
if raw.isdigit():
|
||||
song_id = raw
|
||||
else:
|
||||
try:
|
||||
kind, parsed_id = parse_netease_link(raw)
|
||||
except ValueError as err:
|
||||
raise RuntimeError("无效的网易云歌曲链接") from err
|
||||
if kind == "playlist":
|
||||
raise RuntimeError("检测到网易云歌单链接,请使用 parsePlaylist")
|
||||
if kind == "album":
|
||||
raise RuntimeError("检测到网易云专辑链接,请使用 parseAlbum")
|
||||
song_id = parsed_id
|
||||
|
||||
song = await fetch_song_by_id(song_id)
|
||||
try:
|
||||
# 延迟导入:apis.wy 反向依赖 platforms.wy.utils,模块级互相 import 会成环
|
||||
from app.sdk.apis import wy as wy_api
|
||||
|
||||
hit = await wy_api.get_music_url(song, "320k")
|
||||
song["url"] = hit["url"]
|
||||
song["type"] = hit["type"]
|
||||
except Exception as err: # noqa: BLE001 - 与上游一致:直链失败忽略(VIP/下架曲目仍返回元数据)
|
||||
print(f"[wy] 解析歌曲时取流失败(已忽略): {err}")
|
||||
return song
|
||||
|
||||
|
||||
async def parse_album(link: str) -> dict[str, Any]:
|
||||
return await parse_album_link(link)
|
||||
|
||||
|
||||
async def parse_playlist(link: str) -> dict[str, Any]:
|
||||
return await parse_playlist_link(link)
|
||||
@@ -0,0 +1,19 @@
|
||||
"""网易云封面(移植自 sdk/wy/index.ts 的 getPic → musicInfo.ts 的 song_detail)。
|
||||
|
||||
原文行为:取 song/detail 的 `al.picUrl`,失败即报错(无降级链)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.wy.music_info import get_music_info
|
||||
|
||||
|
||||
async def get_pic(song_info: dict[str, Any]) -> str:
|
||||
info = await get_music_info(song_info)
|
||||
album = info.get("al") or {}
|
||||
url = album.get("picUrl")
|
||||
if not url:
|
||||
raise RuntimeError("获取封面失败")
|
||||
return str(url)
|
||||
@@ -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_)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""网易云歌手(移植自 sdk/wy/singer.ts)。
|
||||
|
||||
三个 eapi 接口:
|
||||
* /api/artist/head/info/get → 歌手信息
|
||||
* /api/v2/artist/songs → 歌曲列表(音质档位来自 privilege.chargeInfoList)
|
||||
* /api/artist/albums/<id> → 专辑列表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
|
||||
from app.sdk.platforms.wy.utils import eapi_request
|
||||
|
||||
|
||||
async def get_info(singer_id: str) -> dict[str, Any]:
|
||||
"""歌手信息。失败文案与原文一致:get singer info faild.。"""
|
||||
resp = await eapi_request("/api/artist/head/info/get", {"id": singer_id})
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if not body or body.get("code") != 200:
|
||||
raise RuntimeError("get singer info faild.")
|
||||
artist = (body.get("data") or {}).get("artist")
|
||||
if not isinstance(artist, dict):
|
||||
raise RuntimeError("get singer info faild.")
|
||||
return {
|
||||
"source": "wy",
|
||||
"id": artist.get("id"),
|
||||
"info": {
|
||||
"name": artist.get("name"),
|
||||
"desc": artist.get("briefDesc"),
|
||||
"avatar": artist.get("avatar") or artist.get("cover") or None,
|
||||
"gender": None,
|
||||
},
|
||||
"count": {
|
||||
"music": artist.get("musicSize"),
|
||||
"album": artist.get("albumSize"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _size_of(block: Any) -> str | None:
|
||||
if isinstance(block, dict) and block.get("size"):
|
||||
return size_formate(block["size"])
|
||||
return None
|
||||
|
||||
|
||||
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
|
||||
"""歌手歌曲 → SongInfo。档位来自 privilege.chargeInfoList 的 rate(无直落,逐条 break)。"""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in raw or []:
|
||||
if not isinstance(item, dict) or not item.get("id"):
|
||||
continue
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
privilege = item.get("privilege") or {}
|
||||
for info in privilege.get("chargeInfoList") or []:
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
rate = info.get("rate")
|
||||
if rate == 128000:
|
||||
size = _size_of(item.get("lMusic"))
|
||||
types.append({"type": "128k", "size": size})
|
||||
_types["128k"] = {"size": size}
|
||||
elif rate == 320000:
|
||||
size = _size_of(item.get("hMusic"))
|
||||
types.append({"type": "320k", "size": size})
|
||||
_types["320k"] = {"size": size}
|
||||
elif rate == 999000:
|
||||
size = _size_of(item.get("sqMusic"))
|
||||
types.append({"type": "flac", "size": size})
|
||||
_types["flac"] = {"size": size}
|
||||
elif rate == 1999000:
|
||||
size = _size_of(item.get("hrMusic"))
|
||||
types.append({"type": "flac24bit", "size": size})
|
||||
_types["flac24bit"] = {"size": size}
|
||||
|
||||
raw_album = item.get("album")
|
||||
album: dict[str, Any] = raw_album if isinstance(raw_album, dict) else {}
|
||||
rows.append(
|
||||
{
|
||||
"singer": format_singer_name(item.get("artists")),
|
||||
"name": item.get("name"),
|
||||
"albumName": album.get("name"),
|
||||
"albumId": album.get("id"),
|
||||
"songmid": item.get("id"),
|
||||
"source": "wy",
|
||||
"interval": format_play_time((item.get("dt") or item.get("duration") or 0) / 1000),
|
||||
"img": album.get("picUrl"),
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def get_song_list(singer_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""歌手歌曲列表。失败文案:get singer song list faild.。"""
|
||||
resp = await eapi_request("/api/v2/artist/songs", {"id": singer_id, "limit": limit, "offset": (page - 1) * limit})
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if not body.get("songs") or body.get("code") != 200:
|
||||
raise RuntimeError("get singer song list faild.")
|
||||
return {
|
||||
"list": filter_song_list(body.get("songs")),
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"total": body.get("total"),
|
||||
"source": "wy",
|
||||
}
|
||||
|
||||
|
||||
def filter_album_list(raw: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in raw or []:
|
||||
if not isinstance(item, dict) or not item.get("id"):
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"count": item.get("size"),
|
||||
"info": {
|
||||
"name": item.get("name"),
|
||||
"author": format_singer_name(item.get("artists")),
|
||||
"img": item.get("picUrl"),
|
||||
"desc": None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def get_album_list(singer_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""歌手专辑列表。失败文案:get singer album list faild.。"""
|
||||
resp = await eapi_request(f"/api/artist/albums/{singer_id}", {"limit": limit, "offset": (page - 1) * limit})
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if not body.get("hotAlbums") or body.get("code") != 200:
|
||||
raise RuntimeError("get singer album list faild.")
|
||||
artist = body.get("artist") or {}
|
||||
return {
|
||||
"source": "wy",
|
||||
"list": filter_album_list(body.get("hotAlbums")),
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"total": artist.get("albumSize"),
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
"""网易云歌单(移植 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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,54 @@
|
||||
"""网易云 eapi 请求通道(移植自 sdk/wy/utils/index.ts)。
|
||||
|
||||
登录后携带 MUSIC_U(见 app/sdk/auth/wy.py),未登录不附加 —— 匿名接口同样要求
|
||||
eapi 签名,区别只在 cookie 决定的可访问范围/音质。
|
||||
|
||||
`weapi_headers(with_cookie=False)` 用于原实现里本就不带 cookie 的 weapi 请求
|
||||
(playlist/list、catalogue、hottags、comment、toplist),避免登录态意外改变返回内容。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.auth_store import get_wy_cookie
|
||||
from app.sdk.common.request import HttpResponse, http_fetch
|
||||
from app.sdk.platforms.wy.utils.crypto import eapi
|
||||
|
||||
UA_LINUX_CHROME = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"
|
||||
)
|
||||
UA_WIN_CHROME = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
|
||||
def weapi_headers(referer: str = "https://music.163.com/", *, with_cookie: bool = True) -> dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": UA_LINUX_CHROME,
|
||||
"Referer": referer,
|
||||
"origin": "https://music.163.com",
|
||||
}
|
||||
if with_cookie:
|
||||
cookie = get_wy_cookie()
|
||||
if cookie:
|
||||
headers["cookie"] = cookie
|
||||
return headers
|
||||
|
||||
|
||||
async def eapi_request(
|
||||
url: str,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
host: str = "https://interface.music.163.com/eapi/batch",
|
||||
timeout_ms: int = 60_000,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> HttpResponse:
|
||||
"""POST 到 eapi 通道。`url` 是业务路径(如 /api/song/enhance/player/url/v1)。"""
|
||||
merged = {"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"}
|
||||
cookie = get_wy_cookie()
|
||||
if cookie:
|
||||
merged["cookie"] = cookie
|
||||
if headers:
|
||||
merged.update(headers)
|
||||
return await http_fetch(host, method="post", headers=merged, form=eapi(url, data), timeout=timeout_ms)
|
||||
@@ -0,0 +1,99 @@
|
||||
"""eapi / weapi / linuxapi 加密 —— 移植自 sdk/wy/utils/crypto.ts。
|
||||
|
||||
算法与上游 Binaryify/NeteaseCloudMusicApi 的 util/crypto.js 逐字节一致:
|
||||
* weapi 两层 AES-128-CBC(内层固定 presetKey,外层随机 secretKey),再用 RSA_NO_PADDING 包 secretKey
|
||||
* linuxapi / eapi 走 AES-128-ECB
|
||||
* Node 的 createCipheriv 默认 PKCS#7 自动补位,这里手工补齐;ECB 忽略 iv
|
||||
|
||||
改这里会让所有 eapi 请求的签名失效,必须与上游保持一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from cryptography.hazmat.primitives import padding as _sym_padding
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
IV = b"0102030405060708"
|
||||
PRESET_KEY = b"0CoJUm6Qyw8W8jud"
|
||||
LINUXAPI_KEY = b"rFgB&h#%2?^eDg:Q"
|
||||
EAPI_KEY = b"e82ckenh8dichen8"
|
||||
BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
PUBLIC_KEY = (
|
||||
"-----BEGIN PUBLIC KEY-----\n"
|
||||
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2"
|
||||
"KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBG"
|
||||
"LbCiK45wIDAQAB\n"
|
||||
"-----END PUBLIC KEY-----\n"
|
||||
)
|
||||
|
||||
|
||||
def _aes_encrypt(data: bytes, key: bytes, iv: bytes = b"") -> bytes:
|
||||
"""AES 加密;iv 为空走 ECB,否则 CBC。补位方式与 Node 默认一致(PKCS#7)。"""
|
||||
padder = _sym_padding.PKCS7(128).padder()
|
||||
padded = padder.update(data) + padder.finalize()
|
||||
cipher = Cipher(algorithms.AES(key), modes.CBC(iv) if iv else modes.ECB())
|
||||
encryptor = cipher.encryptor()
|
||||
return encryptor.update(padded) + encryptor.finalize()
|
||||
|
||||
|
||||
def _aes_decrypt(data: bytes, key: bytes) -> bytes:
|
||||
cipher = Cipher(algorithms.AES(key), modes.ECB())
|
||||
decryptor = cipher.decryptor()
|
||||
plain = decryptor.update(data) + decryptor.finalize()
|
||||
unpadder = _sym_padding.PKCS7(128).unpadder()
|
||||
return unpadder.update(plain) + unpadder.finalize()
|
||||
|
||||
|
||||
def _rsa_encrypt(data: bytes, key: str = PUBLIC_KEY) -> bytes:
|
||||
"""RSA_NO_PADDING:调用方需自行左补零到模长(Node 版同样是手工 Buffer.alloc 补齐)。
|
||||
|
||||
cryptography 不提供 NoPadding(其 RSA 加密强制 PKCS#1 填充),而网易 weapi 的
|
||||
secretKey 必须裸加密才与上游一致,因此这里直接做 c = m^e mod n。
|
||||
"""
|
||||
public_key = serialization.load_pem_public_key(key.encode())
|
||||
if not isinstance(public_key, rsa.RSAPublicKey):
|
||||
raise RuntimeError("网易云 RSA 公钥解析失败")
|
||||
numbers = public_key.public_numbers()
|
||||
block_size = public_key.key_size // 8
|
||||
if len(data) > block_size:
|
||||
raise RuntimeError("网易云 RSA 明文超过模长")
|
||||
block = b"\x00" * (block_size - len(data)) + data
|
||||
return pow(int.from_bytes(block, "big"), numbers.e, numbers.n).to_bytes(block_size, "big")
|
||||
|
||||
|
||||
def _js_json(data: Any) -> str:
|
||||
"""对齐 JS 的 JSON.stringify(紧凑、保留非 ASCII)。"""
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def weapi(data: Any) -> dict[str, str]:
|
||||
text = _js_json(data)
|
||||
# Node 的 randomBytes 是密码学随机,用 os.urandom 对齐;取模 62 后映射到 base62 字符的 ASCII 码
|
||||
secret_key = bytes(ord(BASE62[byte % 62]) for byte in os.urandom(16))
|
||||
inner = base64.b64encode(_aes_encrypt(text.encode(), PRESET_KEY, IV))
|
||||
params = base64.b64encode(_aes_encrypt(inner, secret_key, IV)).decode()
|
||||
return {"params": params, "encSecKey": _rsa_encrypt(secret_key[::-1]).hex()}
|
||||
|
||||
|
||||
def linuxapi(data: Any) -> dict[str, str]:
|
||||
text = _js_json(data)
|
||||
return {"eparams": _aes_encrypt(text.encode(), LINUXAPI_KEY).hex().upper()}
|
||||
|
||||
|
||||
def eapi(url: str, data: Any) -> dict[str, str]:
|
||||
text = data if isinstance(data, str) else _js_json(data)
|
||||
digest = hashlib.md5(f"nobody{url}use{text}md5forencrypt".encode()).hexdigest()
|
||||
raw = f"{url}-36cd479b6b5-{text}-36cd479b6b5-{digest}"
|
||||
return {"params": _aes_encrypt(raw.encode(), EAPI_KEY).hex().upper()}
|
||||
|
||||
|
||||
def eapi_decrypt(cipher_buffer: bytes) -> str:
|
||||
return _aes_decrypt(cipher_buffer, EAPI_KEY).decode("utf-8", errors="replace")
|
||||
Reference in new issue
Block a user