Files
MusicServer/app/sdk/platforms/kg/playlist.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

234 lines
9.1 KiB
Python

"""酷狗歌单模块(对应 sdk/kg/playlist.ts)—— 统一形状层。
解析链复用 song_list 的能力(special/single、songlist/gcid、share chain、zlist、
global_collection_id 等),本模块只做形状归一:摘要 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg import song_list
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _playlist_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
def _to_summary(item: dict[str, Any]) -> dict[str, Any] | None:
raw_id = _first_non_empty(item.get("specialid"), item.get("global_specialid"), item.get("id"))
playlist_id = re.sub(r"^id_", "", raw_id)
if not playlist_id:
return None
play_count = _finite(item.get("playcount"))
return {
"id": playlist_id,
"name": _first_non_empty(item.get("specialname"), item.get("name")),
"cover": _playlist_cover(_first_non_empty(item.get("imgurl"), item.get("img"))),
"trackCount": int(item.get("songcount") or item.get("total") or 0) or 0,
"playCount": play_count if play_count is not None else (item.get("play_count") or 0),
"creator": _first_non_empty(item.get("nickname"), item.get("username"), item.get("author")),
"description": _first_non_empty(item.get("intro"), item.get("desc")),
"publishTime": str(item.get("publishtime") or item.get("time") or "").strip(),
"link": f"https://www.kugou.com/yy/special/single/{playlist_id}.html",
"source": "kg",
}
def _finite(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number and number not in (float("inf"), float("-inf")) else None
def _normalize_playlist_id(id: Any) -> str:
"""数字 id / id_ 前缀 / 链接 → song_list.get_list_detail 可识别的入参。"""
raw = str(id or "").strip()
if not raw:
return ""
return f"id_{raw}" if re.fullmatch(r"\d+", raw) else raw
def _parse_category_id(category_id: Any) -> tuple[str, str]:
parts = str(category_id or "").strip().split(":")
return str(parts[0] if parts else "").strip(), (str(parts[1]).strip() if len(parts) > 1 else "") or "0"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
res = await song_list.search(word, page, limit)
items = [summary for summary in (_to_summary(item) for item in res.get("list") or []) if summary]
return {
"source": "kg",
"list": items,
"page": page,
"limit": limit,
"total": int(res.get("total") or 0) or len(items),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
request_id = _normalize_playlist_id(playlist_id)
if not request_id:
raise RuntimeError("酷狗歌单 id 为空")
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
res = await song_list.get_list_detail(request_id, page_no)
if not res:
raise RuntimeError("酷狗歌单不存在或解析失败")
songs: list[dict[str, Any]] = res.get("list") or []
total = int(res.get("total") or 0) or len(songs)
if len(songs) > page_size:
start = (page_no - 1) * page_size
songs = songs[start : start + page_size]
info = res.get("info") or {}
return {
"source": "kg",
"info": {
"name": _first_non_empty(info.get("name"), str(playlist_id)),
"img": _playlist_cover(info.get("img")),
"desc": _first_non_empty(info.get("desc")),
"author": _first_non_empty(info.get("author")),
"play_count": info.get("play_count") or "",
},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
res = await song_list.get_list_detail(_normalize_playlist_id(playlist_id), 1)
return (res or {}).get("list") or []
async def get_categories() -> dict[str, Any]:
resp = await http_fetch(
"http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗歌单分类获取失败"))
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
for group in (body.get("data") or {}).get("info") or []:
group_name = str(group.get("name") or "").strip()
group_id = int(group.get("id") or 0) or 0
if group_id > 0 and group_name:
items.append({"id": f"{group_id}:0", "name": group_name, "group": group_name})
for child in group.get("children") or []:
child_id = int(child.get("id") or 0) or 0
child_name = str(child.get("name") or "").strip()
if not child_id or not child_name:
continue
tag_id = int(child.get("special_tag_id") or 0) or 0
items.append(
{
"id": f"{child_id}:{tag_id}",
"name": child_name,
"group": group_name,
"hot": int(child.get("is_hot") or 0) == 1,
}
)
return {"source": "kg", "list": items}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
category, tag_id = _parse_category_id(category_id)
if not category:
listing = await get_categories()
first = next((item for item in listing["list"] if item.get("id")), None)
if not first:
raise RuntimeError("酷狗歌单分类为空")
category, tag_id = _parse_category_id(first["id"])
api_url = "http://mobilecdnbj.kugou.com/api/v3/tag/specialList?" + "&".join(
["plat=0", f"page={page_no}", f"tagid={tag_id}", f"pagesize={page_size}", "ugc=1", f"id={category}", "sort=2"]
)
resp = await http_fetch(api_url, headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗分类歌单获取失败"))
items: list[dict[str, Any]] = []
for entry in (body.get("data") or {}).get("info") or []:
summary = _to_summary(entry) or _to_summary({**entry, "specialid": entry.get("global_specialid")})
if not summary:
continue
summary["categoryId"] = f"{category}:{tag_id}"
items.append(summary)
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int((body.get("data") or {}).get("total") or 0) or len(items),
}
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 30))
resp = await http_fetch(
f"http://m.kugou.com/plist/index&json=true&page={page_no}",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError("酷狗推荐歌单获取失败")
plist = (body.get("plist") or {}).get("list") or {}
items = [summary for summary in (_to_summary(item) for item in plist.get("info") or []) if summary]
if len(items) > page_size:
items = items[:page_size]
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int(plist.get("total") or 0) or len(items),
}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""special/single/<id>.html 与 songlist/<gcid_xxx>/ 优先,其余交给 song_list 通用解析。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的酷狗歌单链接")
special = re.search(r"special/single/(\d+)\.html", raw)
if special:
return await get_playlist_detail(special.group(1), 1)
gcid = re.search(r"songlist/(gcid_[A-Za-z0-9]+)", raw)
if gcid:
return await get_playlist_detail(gcid.group(1), 1)
return await get_playlist_detail(raw, 1)