"""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("
", "\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("
", "\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