Files
MusicServer/app/sdk/platforms/tx/song_list.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

349 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""QQ音乐歌单广场 + 歌单详情(移植自 sdk/tx/songList.ts)。
一个模块承载三件事(原文也是这样,路由按能力位分别用):
* song_list —— 分类标签与分类歌单列表(get_tag / get_hot_tag / get_list)
* playlist —— 歌单详情(get_list_detail)与歌单搜索(search)
* parse —— 歌单链接 → 数字 id 归一(get_list_id,含 302 短链兜底)
分类歌单固定 36 条一页(limit_list),调用方要的 limit 在 playlist 层再切片。
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, format_singer_name
from app.sdk.platforms.tx.filter import build_types, song_img
LIMIT_LIST = 36
SUCCESS_CODE = 0
SORT_LIST: list[dict[str, Any]] = [
{"name": "最热", "id": 5},
{"name": "最新", "id": 2},
]
_HOT_TAG_HTML = re.compile(r'class="c_bg_link js_tag_item" data-id="\w+">.+?</a>')
_HOT_TAG = re.compile(r'data-id="(\w+)">(.+?)</a>')
# https://y.qq.com/n/yqq/playlist/7217720898.html
# https://i.y.qq.com/n2/m/share/details/taoge.html?...&id=7217720898&ADTAG=qfshare
_LIST_DETAIL_LINK = re.compile(r"/playlist/(\d+)")
_LIST_DETAIL_LINK2 = re.compile(r"id=(\d+)")
_BASE_QUERY = (
"loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&notice=0&platform=wk_v15.json&needNewCode=0"
)
TAGS_URL = f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(json.dumps({'tags': {'method': 'get_all_categories', 'param': {'qq': ''}, 'module': 'playlist.PlaylistAllCategoriesServer'}}, ensure_ascii=False, separators=(',', ':')), safe='')}"
HOT_TAG_URL = "https://c.y.qq.com/node/pc/wk_v15/category_playlist.html"
LIST_DETAIL_URL = (
"https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg"
"?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid={playlist_id}"
"&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&notice=0"
"&platform=yqq.json&needNewCode=0"
)
SEARCH_URL = (
"http://c.y.qq.com/soso/fcgi-bin/client_music_search_songlist"
"?page_no={page}&num_per_page={limit}&format=json&query={query}"
"&remoteplace=txt.yqq.playlist&inCharset=utf8&outCharset=utf-8"
)
_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
def get_list_url(sort_id: Any, tag_id: Any, page: int) -> str:
"""分类歌单列表 URL:tagId 非空走分类内容接口,否则走「全部」广场接口。"""
if tag_id:
data: dict[str, Any] = {
"comm": {"cv": 1602, "ct": 20},
"playlist": {
"method": "get_category_content",
"param": {
"titleid": int(tag_id),
"caller": "0",
"category_id": int(tag_id),
"size": LIMIT_LIST,
"page": page - 1,
"use_page": 1,
},
"module": "playlist.PlayListCategoryServer",
},
}
else:
data = {
"comm": {"cv": 1602, "ct": 20},
"playlist": {
"method": "get_playlist_by_tag",
"param": {
"id": 10000000,
"sin": LIMIT_LIST * (page - 1),
"size": LIMIT_LIST,
"order": sort_id,
"cur_page": page,
},
"module": "playlist.PlayListPlazaServer",
},
}
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
return f"https://u.y.qq.com/cgi-bin/musicu.fcg?{_BASE_QUERY}&data={quote(payload, safe='')}"
def filter_tag_info(raw_list: Any) -> list[dict[str, Any]]:
return [
{
"name": group.get("group_name"),
"list": [
{
"parent_id": group.get("group_id"),
"parent_name": group.get("group_name"),
"id": item.get("id"),
"name": item.get("name"),
"source": "tx",
}
for item in group.get("v_item") or []
],
}
for group in raw_list or []
]
def filter_info_hot_tag(html: Any) -> list[dict[str, Any]]:
tags: list[dict[str, Any]] = []
if not isinstance(html, str):
return tags
for tag_html in _HOT_TAG_HTML.findall(html):
result = _HOT_TAG.search(tag_html)
if not result:
continue
try:
tag_id = int(result.group(1))
except ValueError:
continue
tags.append({"id": tag_id, "name": result.group(2), "source": "tx"})
return tags
def filter_list(data: dict[str, Any], page: int) -> dict[str, Any]:
"""「全部」广场分支(get_playlist_by_tag)。"""
return {
"list": [
{
"play_count": format_play_count(item.get("access_num")),
"id": str(item.get("tid")),
"author": (item.get("creator_info") or {}).get("nick"),
"name": item.get("title"),
"time": date_format(item["modify_time"] * 1000, "Y-M-D") if item.get("modify_time") else "",
"img": item.get("cover_url_medium"),
"total": len(item.get("song_ids") or []),
"desc": decode_name(item.get("desc")).replace("<br>", "\n"),
"source": "tx",
}
for item in data.get("v_playlist") or []
],
"total": data.get("total"),
"page": page,
"limit": LIMIT_LIST,
"source": "tx",
}
def filter_list2(data: dict[str, Any], page: int) -> dict[str, Any]:
"""分类内容分支(get_category_content)。"""
content = data.get("content") or {}
rows: list[dict[str, Any]] = []
for item in content.get("v_item") or []:
basic = item.get("basic") or {}
cover = basic.get("cover") or {}
rows.append(
{
"play_count": format_play_count(basic.get("play_cnt")),
"id": str(basic.get("tid")),
"author": (basic.get("creator") or {}).get("nick"),
"name": basic.get("title"),
"img": cover.get("medium_url") or cover.get("default_url"),
"desc": decode_name(basic.get("desc")).replace("<br>", "\n"),
"source": "tx",
}
)
return {
"list": rows,
"total": content.get("total_cnt"),
"page": page,
"limit": LIMIT_LIST,
"source": "tx",
}
def filter_list_detail(raw_list: Any) -> list[dict[str, Any]]:
"""歌单曲目 → SongInfo(比搜索结果多带 lrc/otherSource 两个空字段,与原文一致)。"""
songs: list[dict[str, Any]] = []
for item in raw_list or []:
file = (item or {}).get("file") or {}
types, _types = build_types(file)
album = item.get("album") or {}
album_name = album.get("name") or ""
album_mid = album.get("mid") or ""
songs.append(
{
"singer": format_singer_name(item.get("singer"), "name"),
"name": item.get("title"),
"albumName": album_name,
"albumId": album_mid,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album_mid,
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": song_img("" if album_name in ("", "空") else album_mid, item.get("singer")),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return songs
async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(TAGS_URL)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(retry_num + 1)
return filter_tag_info(((body.get("tags") or {}).get("data") or {}).get("v_group"))
async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_TAG_URL)
if resp.status_code != 200:
return await get_hot_tag(retry_num + 1)
return filter_info_hot_tag(resp.body)
async def get_tags() -> dict[str, Any]:
"""分类标签 + 热门标签。原 TS 用 Promise.all(任一失败整体失败),
这里改为独立兜底:分类页少一个分组比整页 404 更有用。"""
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag(), return_exceptions=True)
return {
"tags": [] if isinstance(tags, BaseException) else tags,
"hotTag": [] if isinstance(hot_tag, BaseException) else hot_tag,
"source": "tx",
}
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_list_url(sort_id, tag_id, page))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, retry_num + 1)
data = (body.get("playlist") or {}).get("data") or {}
return filter_list2(data, page) if tag_id else filter_list(data, page)
async def handle_parse_id(link: str, retry_num: int = 0) -> str:
"""短链跟随:请求层不自动跟随重定向,手动读 Location(文案与原文一致:link try max num)。"""
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)
return resp.header("location") or link
async def get_list_id(playlist_id: str) -> str:
"""链接/裸 id → 纯数字歌单 id。"""
if not re.search(r"[?&:/]", playlist_id):
return playlist_id
target = playlist_id
if not _LIST_DETAIL_LINK.search(target):
target = await handle_parse_id(target)
result = _LIST_DETAIL_LINK.search(target)
if not result:
result = _LIST_DETAIL_LINK2.search(target)
if not result:
raise RuntimeError("failed")
return result.group(1)
async def get_list_detail(playlist_id: str, retry_num: int = 0) -> dict[str, Any]:
"""歌单详情(含全部曲目,不分页)。重试文案与原文一致:try max num。"""
if retry_num > 2:
raise RuntimeError("try max num")
bare = await get_list_id(playlist_id)
resp = await http_fetch(
LIST_DETAIL_URL.format(playlist_id=quote(bare, safe="")),
headers={"Origin": "https://y.qq.com", "Referer": f"https://y.qq.com/n/yqq/playsquare/{bare}.html"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_list_detail(bare, retry_num + 1)
cdlist = (body.get("cdlist") or [{}])[0]
songlist = cdlist.get("songlist") or []
return {
"list": filter_list_detail(songlist),
"page": 1,
"limit": len(songlist) + 1,
"total": len(songlist),
"source": "tx",
"info": {
"name": cdlist.get("dissname"),
"img": cdlist.get("logo"),
"desc": decode_name(cdlist.get("desc")).replace("<br>", "\n"),
"author": cdlist.get("nickname"),
"play_count": format_play_count(cdlist.get("visitnum")),
},
}
async def search(text: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
"""歌单搜索。重试上限 5 次(文案与原文一致:max retry)。"""
if retry_num > 5:
raise RuntimeError("max retry")
resp = await http_fetch(
SEARCH_URL.format(page=page - 1, limit=limit, query=quote(text, safe="")),
headers={"User-Agent": _UA_IE, "Referer": "http://y.qq.com/portal/search.html"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 0:
return await search(text, page, limit, retry_num + 1)
data = body.get("data") or {}
return {
"list": [
{
"play_count": format_play_count(item.get("listennum")),
"id": str(item.get("dissid")),
"author": decode_name((item.get("creator") or {}).get("name")),
"name": decode_name(item.get("dissname")),
"time": date_format(item.get("createtime"), "Y-M-D"),
"img": item.get("imgurl"),
"total": item.get("song_count"),
"desc": decode_name(decode_name(item.get("introduction"))).replace("<br>", "\n"),
"source": "tx",
}
for item in data.get("list") or []
],
"limit": limit,
"total": data.get("sum"),
"source": "tx",
}
async def get_detail_page_url(playlist_id: str) -> str:
return f"https://y.qq.com/n/ryqq/playlist/{await get_list_id(playlist_id)}"
sort_list = SORT_LIST