- 新增 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 路由验证通过
300 lines
13 KiB
Python
300 lines
13 KiB
Python
"""QQ音乐歌单能力位(移植自 sdk/tx/playlist.ts)。
|
||
|
||
复用 song_list 的搜索/分类/详情,另补三块上游独有的逻辑:
|
||
* 分类歌单按入参 limit 重新切片(上游固定 36 条一页,需多次拉取拼够)
|
||
* 推荐歌单(musicu.fcg playlist.HotRecommendServer/get_hot_recommend)
|
||
* 歌单链接解析(extract_playlist_id 支持现代 ryqq 路由 / 老版 id、disstid 查询,
|
||
并显式拒绝歌曲/专辑链接,避免把其中的数字 id 误判成歌单)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
from urllib.parse import parse_qs, urlparse
|
||
|
||
from app.sdk.common.request import http_fetch
|
||
from app.sdk.common.utils import format_play_count
|
||
from app.sdk.platforms.tx import song_list
|
||
from app.sdk.platforms.tx.utils import MUSICU_URL
|
||
|
||
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||
_API_LIMIT = song_list.LIMIT_LIST
|
||
_DIGITS = re.compile(r"^\d+$")
|
||
# 歌曲/专辑页特征:出现即拒绝(歌单链接可能带 ?id=,故先看是否含 playlist)
|
||
_NON_PLAYLIST = re.compile(r"(albumDetail|/album/|albummid=|songDetail|/song/|songmid=|[?&]songid=)")
|
||
_BARE_PLAYLIST_PATH = re.compile(r"(?:^|/)playlist/(\d+)(?:[/?#]|$)")
|
||
|
||
|
||
def _playlist_link(playlist_id: str) -> str:
|
||
return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}"
|
||
|
||
|
||
def _to_summary(item: Any) -> dict[str, Any] | None:
|
||
"""歌单摘要:兼容 song_list 的既有字段并补 spec 形状。"""
|
||
if not isinstance(item, dict):
|
||
return None
|
||
playlist_id = str(item.get("id") or "").strip()
|
||
if not playlist_id:
|
||
return None
|
||
cover = str(item.get("img") or item.get("cover") or "")
|
||
track_count = item.get("total")
|
||
return {
|
||
**item,
|
||
"id": playlist_id,
|
||
"name": item.get("name") or "",
|
||
"cover": cover,
|
||
"trackCount": int(track_count) if isinstance(track_count, (int, float)) else 0,
|
||
"creator": item.get("author") or item.get("creator") or "",
|
||
"description": item.get("desc") or item.get("description") or "",
|
||
"link": _playlist_link(playlist_id),
|
||
"source": "tx",
|
||
}
|
||
|
||
|
||
def _normalize_playlist_id(value: str) -> str:
|
||
trimmed = str(value or "").strip()
|
||
return trimmed if trimmed and _DIGITS.match(trimmed) else ""
|
||
|
||
|
||
def _filter_category_content(raw: Any) -> list[dict[str, Any]]:
|
||
"""分类内容 → 摘要(补 song_cnt/play_cnt,song_list.filter_list2 未映射这两个字段)。"""
|
||
rows: list[dict[str, Any]] = []
|
||
for item in raw or []:
|
||
basic = (item or {}).get("basic") or {}
|
||
playlist_id = str(basic.get("tid") or "").strip()
|
||
name = str(basic.get("title") or "").strip()
|
||
if not playlist_id or not name:
|
||
continue
|
||
play_count = basic.get("play_cnt") or 0
|
||
cover = str((basic.get("cover") or {}).get("medium_url") or (basic.get("cover") or {}).get("default_url") or "")
|
||
desc = str(basic.get("desc") or "").replace("<br>", "\n")
|
||
track_count = basic.get("song_cnt") or 0
|
||
rows.append(
|
||
{
|
||
"id": playlist_id,
|
||
"name": name,
|
||
"img": cover,
|
||
"cover": cover,
|
||
"author": (basic.get("creator") or {}).get("nick") or "",
|
||
"creator": (basic.get("creator") or {}).get("nick") or "",
|
||
"play_count": format_play_count(play_count),
|
||
"playCount": int(play_count),
|
||
"trackCount": int(track_count),
|
||
"total": int(track_count),
|
||
"desc": desc,
|
||
"description": desc,
|
||
"source": "tx",
|
||
}
|
||
)
|
||
return rows
|
||
|
||
|
||
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 summary in (_to_summary(item) for item in result.get("list") or []) if summary]
|
||
total = result.get("total")
|
||
return {"list": rows, "page": page, "limit": limit, "total": total or len(rows), "source": "tx"}
|
||
|
||
|
||
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||
"""歌单详情:详情接口返回全量曲目,这里按 page/limit 切片。"""
|
||
detail = await song_list.get_list_detail(str(playlist_id or ""))
|
||
songs = detail.get("list") or []
|
||
start = max(0, (page - 1) * limit)
|
||
total = detail.get("total")
|
||
return {
|
||
"source": "tx",
|
||
"info": detail.get("info"),
|
||
"list": songs[start : start + limit],
|
||
"page": page,
|
||
"limit": limit,
|
||
"total": total if total is not None else len(songs),
|
||
}
|
||
|
||
|
||
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
|
||
detail = await song_list.get_list_detail(str(playlist_id or ""))
|
||
return detail.get("list") or []
|
||
|
||
|
||
async def get_categories() -> dict[str, Any]:
|
||
"""歌单分类:分组标签 + 热门标记(单个接口失败不阻塞整体)。"""
|
||
tags = await song_list.get_tags()
|
||
groups = tags.get("tags") or []
|
||
hot_tags = tags.get("hotTag") or []
|
||
|
||
hot_set = {str(item.get("id")) for item in hot_tags if isinstance(item, dict)}
|
||
rows: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
|
||
seen = {""}
|
||
for group in groups:
|
||
for item in group.get("list") or []:
|
||
category_id = str(item.get("id") or "").strip()
|
||
name = str(item.get("name") or "").strip()
|
||
if not category_id or not name or category_id in seen:
|
||
continue
|
||
seen.add(category_id)
|
||
rows.append(
|
||
{"id": category_id, "name": name, "group": group.get("name") or "", "hot": category_id in hot_set}
|
||
)
|
||
return {"source": "tx", "list": rows}
|
||
|
||
|
||
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||
"""分类歌单。上游固定 36 条一页,按 (start+limit) 推算需要拉几页再切片。
|
||
|
||
categoryId 为空或 '10000000'(上游「全部」默认值)时走 get_playlist_by_tag 广场分支。
|
||
"""
|
||
category = str(category_id or "").strip()
|
||
is_all = category in ("", "10000000")
|
||
start = max(0, (page - 1) * limit)
|
||
api_pages = max(1, -(-(start + limit) // _API_LIMIT)) # ceil 除法
|
||
|
||
items: list[dict[str, Any]] = []
|
||
total = 0
|
||
for api_page in range(1, api_pages + 1):
|
||
url = song_list.get_list_url("5", "" if is_all else category, api_page)
|
||
resp = await http_fetch(url, timeout=15000)
|
||
body = resp.body if isinstance(resp.body, dict) else {}
|
||
if resp.status_code != 200 or body.get("code") != 0:
|
||
raise RuntimeError(f"tx 分类歌单获取失败 (code {body.get('code') or resp.status_code})")
|
||
data = (body.get("playlist") or {}).get("data") or {}
|
||
if is_all:
|
||
total = int(data.get("total") or total)
|
||
batch = []
|
||
for item in data.get("v_playlist") or []:
|
||
play_count = item.get("access_num") or 0
|
||
playlist_id = str(item.get("tid") or "").strip()
|
||
name = str(item.get("title") or "").strip()
|
||
if not playlist_id or not name:
|
||
continue
|
||
batch.append(
|
||
{
|
||
"id": playlist_id,
|
||
"name": name,
|
||
"cover": str(item.get("cover_url_medium") or item.get("cover_url_big") or ""),
|
||
"creator": (item.get("creator_info") or {}).get("nick") or "",
|
||
"trackCount": len(item.get("song_ids") or []),
|
||
"playCount": int(play_count),
|
||
"play_count": format_play_count(play_count),
|
||
"description": str(item.get("desc") or "").replace("<br>", "\n"),
|
||
}
|
||
)
|
||
items.extend(batch)
|
||
else:
|
||
content = data.get("content") or {}
|
||
total = int(content.get("total_cnt") or total)
|
||
batch = _filter_category_content(content.get("v_item"))
|
||
items.extend(batch)
|
||
if len(batch) < _API_LIMIT:
|
||
break
|
||
|
||
rows = [summary for summary in (_to_summary(item) for item in items[start : start + limit]) if summary]
|
||
return {"list": rows, "page": page, "limit": limit, "total": total or len(items), "source": "tx"}
|
||
|
||
|
||
async def get_recommended(page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||
"""推荐歌单(playlist.HotRecommendServer/get_hot_recommend)。"""
|
||
resp = await http_fetch(
|
||
MUSICU_URL,
|
||
method="post",
|
||
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Content-Type": "application/json"},
|
||
body={
|
||
"comm": {"ct": 24},
|
||
"recomPlaylist": {
|
||
"method": "get_hot_recommend",
|
||
"module": "playlist.HotRecommendServer",
|
||
"param": {"async": 1, "cmd": 2},
|
||
},
|
||
},
|
||
timeout=15000,
|
||
)
|
||
body = resp.body if isinstance(resp.body, dict) else {}
|
||
if resp.status_code != 200 or body.get("code") != 0:
|
||
raise RuntimeError(f"tx 推荐歌单获取失败 (code {body.get('code') or resp.status_code})")
|
||
|
||
rows: list[dict[str, Any]] = []
|
||
for item in ((body.get("recomPlaylist") or {}).get("data") or {}).get("v_hot") or []:
|
||
playlist_id = str(
|
||
item.get("content_id") or item.get("dissid") or item.get("id") or item.get("tid") or ""
|
||
).strip()
|
||
name = str(item.get("title") or item.get("dissname") or item.get("name") or "").strip()
|
||
if not playlist_id or not name:
|
||
continue
|
||
cover = str(item.get("cover") or item.get("imgurl") or "")
|
||
play_count = item.get("listen_num") or item.get("listennum") or 0
|
||
rows.append(
|
||
{
|
||
"id": playlist_id,
|
||
"name": name,
|
||
"cover": cover.replace("http://", "https://", 1) if cover.startswith("http://") else cover,
|
||
"trackCount": int(item.get("song_cnt") or item.get("song_count") or item.get("songnum") or 0),
|
||
"playCount": int(play_count),
|
||
"play_count": format_play_count(play_count),
|
||
"creator": str(item.get("username") or (item.get("creator") or {}).get("nick") or "").strip(),
|
||
"description": "",
|
||
"link": _playlist_link(playlist_id),
|
||
"source": "tx",
|
||
}
|
||
)
|
||
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": "tx"}
|
||
|
||
|
||
async def extract_playlist_id(link: str) -> str | None:
|
||
"""链接 → 数字歌单 id;非歌单链接返回 None。"""
|
||
raw = str(link or "").strip()
|
||
if not raw:
|
||
return None
|
||
|
||
try:
|
||
parsed = urlparse(raw)
|
||
except ValueError:
|
||
parsed = None
|
||
if parsed is not None and parsed.scheme:
|
||
path = parsed.path.lower()
|
||
if "playlist" in path:
|
||
query = parse_qs(parsed.query)
|
||
for key in ("id", "disstid", "dissid"):
|
||
for value in query.get(key) or []:
|
||
found = _normalize_playlist_id(value)
|
||
if found:
|
||
return found
|
||
parts = [part for part in parsed.path.strip("/").split("/") if part]
|
||
for index, part in enumerate(parts):
|
||
if part.lower() == "playlist" and index + 1 < len(parts):
|
||
found = _normalize_playlist_id(parts[index + 1])
|
||
if found:
|
||
return found
|
||
|
||
match = _BARE_PLAYLIST_PATH.search(raw)
|
||
return match.group(1) if match else None
|
||
|
||
|
||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||
"""歌单链接解析:提取 disstid 后取详情;短链走 song_list.get_list_id 兜底。"""
|
||
raw = str(link or "").strip()
|
||
if not raw:
|
||
raise RuntimeError("无效的QQ音乐歌单链接")
|
||
if not re.search(r"playlist", raw, re.IGNORECASE) and _NON_PLAYLIST.search(raw):
|
||
raise RuntimeError("无效的QQ音乐歌单链接")
|
||
|
||
playlist_id = await extract_playlist_id(raw)
|
||
if not playlist_id and not re.search(r"\d", raw):
|
||
raise RuntimeError("无效的QQ音乐歌单链接")
|
||
if not playlist_id:
|
||
try:
|
||
playlist_id = await song_list.get_list_id(raw)
|
||
except (RuntimeError, OSError, TimeoutError):
|
||
playlist_id = ""
|
||
if not playlist_id:
|
||
raise RuntimeError("无效的QQ音乐歌单链接")
|
||
return await get_playlist_detail(playlist_id)
|
||
|
||
|
||
get_recommended_playlists = get_recommended
|