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

293 lines
9.8 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/userPlaylist.ts)。
需登录 cookie(auth_store.get_tx_auth 返回的 cookie,须含 uin 类字段):
未登录 / cookie 里取不到 uin 时**抛出清晰错误**,不静默返回空列表(与原文一致)。
三路数据源,结果按 id 去重:
1) fcg_user_created_diss → 自建歌单
2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
虚拟条目 id 固定为 `profile:favorites`(与原文一致),其歌曲需凭 cookie 走同一接口拉取。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlencode
from app.sdk.auth_store import get_tx_auth
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
FAVORITE_ID = "profile:favorites"
FAVORITE_NAME = "我喜欢的歌曲"
_CREATED_URL = "https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss"
_PROFILE_ASSET_URL = "https://c.y.qq.com/fav/fcgi-bin/fcg_get_profile_order_asset.fcg"
# 上游 normalizeQQUIN 的候选键,按序取第一个非空
_UIN_KEYS = (
"wxuin",
"uin",
"ptui_loginuin",
"luin",
"pt2gguin",
"superuin",
"p_uin",
"musicid",
"userid",
)
def cookie_value(cookie: str, key: str) -> str:
for part in cookie.split(";"):
name, sep, value = part.strip().partition("=")
if sep and name.strip() == key:
return value.strip()
return ""
def normalize_qq_uin(cookie: str) -> str:
"""cookie → uin(去前导 `o` 与 `0`,与原文 normalizeQQUIN 一致)。"""
raw = ""
for key in _UIN_KEYS:
value = cookie_value(cookie, key)
if value:
raw = value
break
return raw.removeprefix("o").lstrip("0").strip()
def _playlist_link(playlist_id: str) -> str:
return f"https://y.qq.com/n/ryqq/playlist/{playlist_id}"
def _normalize_cover(cover: Any) -> str:
value = str(cover or "").strip()
if value.startswith("//"):
return f"https:{value}"
if value.startswith("http://"):
return value.replace("http://", "https://", 1)
return value
def _make_summary(
playlist_id: str,
name: str,
*,
cover: Any = "",
track_count: Any = 0,
play_count: Any = 0,
creator: Any = "",
description: Any = "",
link: str | None = None,
) -> dict[str, Any]:
count = int(play_count or 0)
return {
"id": playlist_id,
"name": name,
"cover": _normalize_cover(cover),
"trackCount": int(track_count or 0),
"playCount": count,
"play_count": format_play_count(count),
"creator": str(creator or ""),
"description": str(description or ""),
"link": link or _playlist_link(playlist_id),
"source": "tx",
}
def _profile_asset_url(uin: str, reqtype: str, page: int, limit: int) -> str:
offset = (max(1, page) - 1) * limit
query = urlencode(
{
"format": "json",
"inCharset": "utf8",
"outCharset": "utf-8",
"platform": "yqq.json",
"needNewCode": "0",
"loginUin": uin,
"hostUin": "0",
"notice": "0",
"g_tk": "5381",
"ct": "20",
"cid": "205360956",
"userid": uin,
"reqtype": reqtype,
"sin": str(offset),
"ein": str(offset + limit - 1),
}
)
return f"{_PROFILE_ASSET_URL}?{query}"
async def _fetch_json(url: str, cookie: str) -> dict[str, Any]:
resp = await http_fetch(
url,
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/", "Cookie": cookie},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or body is None:
raise RuntimeError("tx 个人歌单接口请求失败")
return body
async def _fetch_created(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]:
"""自建歌单(fcg_user_created_diss)。id 优先级:dissid > tid > profile:dir:<dirid>。"""
query = urlencode(
{
"hostuin": uin,
"sin": str((page - 1) * limit),
"size": str(limit),
"format": "json",
"inCharset": "utf8",
"outCharset": "utf-8",
}
)
body = await _fetch_json(f"{_CREATED_URL}?{query}", cookie)
if body.get("code") != 0:
raise RuntimeError(f"tx 自建歌单获取失败: {body.get('message') or ''} (code {body.get('code')})")
data = body.get("data") or {}
rows: list[dict[str, Any]] = []
for item in data.get("disslist") or []:
if not isinstance(item, dict):
continue
diss_id = int(item.get("dissid") or 0)
tid = int(item.get("tid") or 0)
dir_id = int(item.get("dirid") or 0)
if diss_id > 0:
playlist_id = str(diss_id)
elif tid > 0:
playlist_id = str(tid)
elif dir_id > 0:
playlist_id = f"profile:dir:{dir_id}"
else:
playlist_id = ""
name = str(item.get("diss_name") or item.get("title") or "").strip()
if not playlist_id or not name:
continue
rows.append(
_make_summary(
playlist_id,
name,
cover=item.get("diss_cover") or item.get("cover"),
track_count=item.get("song_count") or item.get("song_num") or item.get("song_cnt") or 0,
play_count=item.get("listen_num") or item.get("visitnum") or 0,
creator=uin,
description=item.get("diss_desc") or item.get("desc") or "",
)
)
for item in data.get("list") or []:
if not isinstance(item, dict):
continue
playlist_id = str(item.get("dissid") or "").strip()
name = str(item.get("dissname") or "").strip()
if not playlist_id or not name:
continue
rows.append(
_make_summary(
playlist_id,
name,
cover=item.get("imgurl"),
track_count=item.get("song_count") or item.get("song_num") or 0,
play_count=item.get("listennum") or 0,
creator=uin,
description=item.get("introduction") or "",
)
)
return rows
async def _fetch_collected(cookie: str, uin: str, page: int, limit: int) -> list[dict[str, Any]]:
"""收藏歌单(fcg_get_profile_order_asset reqtype=3)。code 非 0 时返回空列表(同原文)。"""
body = await _fetch_json(_profile_asset_url(uin, "3", page, limit), cookie)
if body.get("code") != 0:
return []
rows: list[dict[str, Any]] = []
for item in (body.get("data") or {}).get("cdlist") or []:
if not isinstance(item, dict):
continue
diss_id = int(item.get("dissid") or 0)
name = str(item.get("dissname") or "").strip()
if diss_id <= 0 or not name:
continue
rows.append(
_make_summary(
str(diss_id),
name,
cover=item.get("logo"),
track_count=item.get("songnum") or 0,
play_count=item.get("listennum") or 0,
creator=item.get("nickname") or item.get("uin") or uin,
)
)
return rows
async def _fetch_favorite_summary(cookie: str, uin: str) -> dict[str, Any] | None:
"""「我喜欢的歌曲」摘要(reqtype=1,limit=1)。总数为 0 时返回 None。"""
body = await _fetch_json(_profile_asset_url(uin, "1", 1, 1), cookie)
if body.get("code") != 0:
return None
data = body.get("data") or {}
total = int(data.get("totalsong") or 0)
if total <= 0:
return None
first = ((data.get("songlist") or [{}])[0] or {}).get("data") or {}
album_mid = first.get("albummid") if isinstance(first, dict) else None
return _make_summary(
FAVORITE_ID,
FAVORITE_NAME,
cover=f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{album_mid}.jpg" if album_mid else "",
track_count=total,
creator=uin,
description="QQ音乐我喜欢的歌曲",
link="https://y.qq.com/n/ryqq/profile",
)
async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]:
"""个人歌单列表。未登录 / 缺 uin 直接抛错,不返回空列表。"""
auth = get_tx_auth()
cookie = str((auth or {}).get("cookie") or "").strip()
if not cookie:
raise RuntimeError("tx 个人歌单需要登录 cookie(请先登录 QQ音乐)")
uin = normalize_qq_uin(cookie)
if not uin:
raise RuntimeError("tx 个人歌单需要 cookie 中的 uin(当前 cookie 不含 uin/wxuin 等字段)")
safe_page = max(1, int(page or 1))
safe_limit = int(limit or 30)
if safe_limit <= 0:
safe_limit = 30
if safe_limit > 100:
safe_limit = 100
rows: list[dict[str, Any]] = []
seen: set[str] = set()
def add(summary: dict[str, Any] | None) -> None:
if not summary:
return
playlist_id = summary.get("id")
if not playlist_id or not summary.get("name") or playlist_id in seen:
return
seen.add(playlist_id)
rows.append(summary)
try:
add(await _fetch_favorite_summary(cookie, uin))
except (RuntimeError, OSError, TimeoutError):
# 摘要失败不阻塞列表(与上游一致)
pass
for item in await _fetch_created(cookie, uin, safe_page, safe_limit):
add(item)
for item in await _fetch_collected(cookie, uin, safe_page, safe_limit):
add(item)
return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "tx"}