- 新增 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 路由验证通过
173 lines
6.8 KiB
Python
173 lines
6.8 KiB
Python
"""网易云专辑(移植自 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_)
|