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 路由验证通过
This commit is contained in:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,62 @@
|
||||
"""QQ音乐(对应 sdk/tx/index.ts 的 default export)。
|
||||
|
||||
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析 / 个人歌单,
|
||||
顶层挂 get_music_url(取流在 apis/tx.py)、get_lyric、get_pic、get_music_info /
|
||||
handle_music_info(songmid → SongInfo,供酷我兜底与评论/歌词反查数字 songId)、
|
||||
get_music_detail_page_url。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms._base import Platform
|
||||
from app.sdk.platforms.tx import (
|
||||
album,
|
||||
comment,
|
||||
hot_search,
|
||||
leaderboard,
|
||||
lyric,
|
||||
music_info,
|
||||
music_search,
|
||||
parse,
|
||||
pic,
|
||||
playlist,
|
||||
singer,
|
||||
song_list,
|
||||
user_playlist,
|
||||
)
|
||||
|
||||
|
||||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""取流入口。延迟导入 apis.tx:apis 模块反向依赖 platforms.tx.utils,
|
||||
模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块(wy 已踩过)。"""
|
||||
from app.sdk.apis import tx as tx_api
|
||||
|
||||
return await tx_api.get_music_url(song_info, quality, retry_num)
|
||||
|
||||
|
||||
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
|
||||
return f"https://y.qq.com/n/yqq/song/{song_info.get('songmid')}.html"
|
||||
|
||||
|
||||
MODULE = Platform(
|
||||
id="tx",
|
||||
name="QQ音乐",
|
||||
music_search=music_search,
|
||||
leaderboard=leaderboard,
|
||||
song_list=song_list,
|
||||
hot_search=hot_search,
|
||||
comment=comment,
|
||||
singer=singer,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
parse=parse,
|
||||
user_playlist=user_playlist,
|
||||
get_music_url=get_music_url,
|
||||
get_lyric=lyric.get_lyric,
|
||||
get_pic=pic.get_pic,
|
||||
get_music_info=music_info.get_music_info,
|
||||
handle_music_info=music_info.handle_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
)
|
||||
@@ -0,0 +1,183 @@
|
||||
"""QQ音乐专辑(移植自 sdk/tx/album.ts)。
|
||||
|
||||
三条路径:
|
||||
* 详情 —— music.musichallAlbum.AlbumSongList/GetAlbumSongList(参数名必须是驼峰
|
||||
`albumMid`,下划线形态会被上游以 104400 拒绝)
|
||||
* 全量曲目 —— 同一接口按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
|
||||
* 搜索 —— c.y.qq.com 的 search_for_qq_cp `t=8`
|
||||
|
||||
GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,
|
||||
封面走平台固定图片 URL 规则。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.tx.filter import album_img, music_info_item
|
||||
from app.sdk.platforms.tx.utils import create_musicu_fetch
|
||||
|
||||
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
||||
ALBUM_BATCH = 100
|
||||
|
||||
SEARCH_URL = "http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p={page}&n={limit}&w={query}&t=8"
|
||||
|
||||
_ALBUM_PATTERNS: tuple[re.Pattern[str], ...] = (
|
||||
re.compile(r"albumDetail/([A-Za-z0-9]+)"),
|
||||
re.compile(r"/album/([A-Za-z0-9]+)"),
|
||||
re.compile(r"albummid=([A-Za-z0-9]+)"),
|
||||
)
|
||||
|
||||
|
||||
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
|
||||
"""GetAlbumSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。"""
|
||||
return [music_info_item((item or {}).get("songInfo")) for item in raw or []]
|
||||
|
||||
|
||||
def album_link(mid: str) -> str:
|
||||
return f"https://y.qq.com/n/ryqq/albumDetail/{mid}"
|
||||
|
||||
|
||||
async def fetch_album_songs(album_id: str) -> dict[str, Any]:
|
||||
"""专辑曲目全量(GetAlbumSongList 100 一批翻页)。
|
||||
|
||||
终止条件与原文一致:本批为空、本批不足一批、或已翻到 totalNum 为止。
|
||||
"""
|
||||
album_mid = str(album_id or "").strip()
|
||||
if not album_mid:
|
||||
raise RuntimeError("tx 专辑 id 为空")
|
||||
|
||||
songs: list[dict[str, Any]] = []
|
||||
total_num = 0
|
||||
begin = 0
|
||||
while True:
|
||||
body = await create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": "music.musichallAlbum.AlbumSongList",
|
||||
"method": "GetAlbumSongList",
|
||||
"param": {"albumMid": album_mid, "begin": begin, "num": ALBUM_BATCH, "order": 2},
|
||||
}
|
||||
}
|
||||
)
|
||||
req = body.get("req") or {}
|
||||
if req.get("code") != 0:
|
||||
raise RuntimeError("get album song list faild.")
|
||||
data = req.get("data") or {}
|
||||
batch = data.get("songList") or []
|
||||
if not total_num:
|
||||
total_num = int(data.get("totalNum") or 0)
|
||||
if not batch:
|
||||
break
|
||||
songs.extend(batch)
|
||||
if len(batch) < ALBUM_BATCH:
|
||||
break
|
||||
if total_num > 0 and begin + len(batch) >= total_num:
|
||||
break
|
||||
begin += ALBUM_BATCH
|
||||
|
||||
first: dict[str, Any] = {}
|
||||
if songs:
|
||||
first = (songs[0] or {}).get("songInfo") or {}
|
||||
return {
|
||||
"raw": songs,
|
||||
"songs": filter_song_list(songs),
|
||||
"total": total_num or len(songs),
|
||||
"albumMid": album_mid,
|
||||
"albumName": (first.get("album") or {}).get("name"),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_detail(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
"""专辑详情 + 曲目(路由优先走这个签名:get_album_detail(id, page, limit))。"""
|
||||
body = await create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": "music.musichallAlbum.AlbumSongList",
|
||||
"method": "GetAlbumSongList",
|
||||
"param": {"albumMid": id, "begin": (page - 1) * limit, "num": limit},
|
||||
}
|
||||
}
|
||||
)
|
||||
req = body.get("req") or {}
|
||||
if req.get("code") != 0:
|
||||
raise RuntimeError("get album song list faild.")
|
||||
|
||||
data = req.get("data") or {}
|
||||
song_list = data.get("songList") or []
|
||||
first: dict[str, Any] = {}
|
||||
if song_list:
|
||||
first = (song_list[0] or {}).get("songInfo") or {}
|
||||
authors: list[str] = []
|
||||
for item in song_list:
|
||||
for singer in ((item or {}).get("songInfo") or {}).get("singer") or []:
|
||||
name = (singer or {}).get("name")
|
||||
if name and name not in authors:
|
||||
authors.append(name)
|
||||
return {
|
||||
"source": "tx",
|
||||
"info": {
|
||||
"name": (first.get("album") or {}).get("name"),
|
||||
"img": album_img(str(id)),
|
||||
"desc": None,
|
||||
"author": "、".join(authors) or None,
|
||||
},
|
||||
"list": filter_song_list(song_list),
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": data.get("totalNum"),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_songs(id: str) -> list[dict[str, Any]]:
|
||||
return (await fetch_album_songs(id))["songs"]
|
||||
|
||||
|
||||
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
"""专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8)。"""
|
||||
resp = await http_fetch(
|
||||
SEARCH_URL.format(page=page, limit=limit, query=quote(str(keyword or ""), safe="")),
|
||||
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/portal/search.html"},
|
||||
timeout=15000,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
raw_data = body.get("data")
|
||||
album_data: dict[str, Any] = (raw_data.get("album") if isinstance(raw_data, dict) else None) or {}
|
||||
if resp.status_code != 200 or not album_data:
|
||||
raise RuntimeError("tx 专辑搜索失败")
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in album_data.get("list") or []:
|
||||
mid = str((item or {}).get("albumMID") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"id": mid,
|
||||
"name": str(item.get("albumName") or "").strip(),
|
||||
"cover": f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{mid}.jpg",
|
||||
"trackCount": int(item.get("song_count") or item.get("songCount") or 0),
|
||||
"creator": str(item.get("singerName") or "").strip(),
|
||||
"description": "",
|
||||
"link": album_link(mid),
|
||||
"type": "album",
|
||||
"albumId": str(item.get("albumID") or ""),
|
||||
"publishTime": str(item.get("publicTime") or ""),
|
||||
"source": "tx",
|
||||
}
|
||||
)
|
||||
total = int(album_data.get("totalnum") or album_data.get("total") or len(rows)) or len(rows)
|
||||
return {"list": rows, "page": page, "limit": limit, "total": total, "source": "tx"}
|
||||
|
||||
|
||||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||||
"""专辑链接解析(上游 ParseAlbum):命中任一形态即取详情,否则原文案报错。"""
|
||||
raw = str(link or "")
|
||||
for pattern in _ALBUM_PATTERNS:
|
||||
match = pattern.search(raw)
|
||||
if match:
|
||||
return await get_album_detail(match.group(1))
|
||||
raise RuntimeError("无效的QQ音乐专辑链接")
|
||||
@@ -0,0 +1,341 @@
|
||||
"""QQ音乐评论(移植自 sdk/tx/comment.ts)。
|
||||
|
||||
两路数据源(与原文一致,接口不同、返回形状不同):
|
||||
* 最新评论 —— c.y.qq.com 的 fcg_global_comment_h5(form 表单,cmd=8)
|
||||
* 热门评论 —— musicu.fcg 的 music.globalComment.CommentRead/GetHotCommentList
|
||||
|
||||
原文靠 `_requestObj.cancelHttp()` 做并发去重;Python 请求层没有取消句柄(见
|
||||
common/request.py 的说明),这里改用 asyncio.Task 合并同一 songmid 的并发查询。
|
||||
|
||||
两处刻意的降级/容错:
|
||||
* `middlecommentcontent` 的父评论头像与点赞数会被搬到第一条楼中楼,父条目置 null,
|
||||
随后楼中楼整体反转 —— 照抄原文,否则前端展示顺序与字段归属会和 TS 版不一致;
|
||||
* 原文 `c.replynick.substring(1)` 在 replynick 缺失时会抛 TypeError 把整页打挂,
|
||||
这里按空串处理(唯一的偏离,只影响脏数据)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import date_format2
|
||||
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
|
||||
from app.sdk.platforms.tx.utils import MUSICU_URL
|
||||
|
||||
EMOJIS: dict[str, str] = {
|
||||
"e400846": "😘",
|
||||
"e400874": "😴",
|
||||
"e400825": "😃",
|
||||
"e400847": "😙",
|
||||
"e400835": "😍",
|
||||
"e400873": "😳",
|
||||
"e400836": "😎",
|
||||
"e400867": "😭",
|
||||
"e400832": "😊",
|
||||
"e400837": "😏",
|
||||
"e400875": "😫",
|
||||
"e400831": "😉",
|
||||
"e400855": "😡",
|
||||
"e400823": "😄",
|
||||
"e400862": "😨",
|
||||
"e400844": "😖",
|
||||
"e400841": "😓",
|
||||
"e400830": "😈",
|
||||
"e400828": "😆",
|
||||
"e400833": "😋",
|
||||
"e400822": "😀",
|
||||
"e400843": "😕",
|
||||
"e400829": "😇",
|
||||
"e400824": "😂",
|
||||
"e400834": "😌",
|
||||
"e400877": "😷",
|
||||
"e400132": "🍉",
|
||||
"e400181": "🍺",
|
||||
"e401067": "☕️",
|
||||
"e400186": "🥧",
|
||||
"e400343": "🐷",
|
||||
"e400116": "🌹",
|
||||
"e400126": "🍃",
|
||||
"e400613": "💋",
|
||||
"e401236": "❤️",
|
||||
"e400622": "💔",
|
||||
"e400637": "💣",
|
||||
"e400643": "💩",
|
||||
"e400773": "🔪",
|
||||
"e400102": "🌛",
|
||||
"e401328": "🌞",
|
||||
"e400420": "👏",
|
||||
"e400914": "🙌",
|
||||
"e400408": "👍",
|
||||
"e400414": "👎",
|
||||
"e401121": "✋",
|
||||
"e400396": "👋",
|
||||
"e400384": "👉",
|
||||
"e401115": "✊",
|
||||
"e400402": "👌",
|
||||
"e400905": "🙈",
|
||||
"e400906": "🙉",
|
||||
"e400907": "🙊",
|
||||
"e400562": "👻",
|
||||
"e400932": "🙏",
|
||||
"e400644": "💪",
|
||||
"e400611": "💉",
|
||||
"e400185": "🎁",
|
||||
"e400655": "💰",
|
||||
"e400325": "🐥",
|
||||
"e400612": "💊",
|
||||
"e400198": "🎉",
|
||||
"e401685": "⚡️",
|
||||
"e400631": "💝",
|
||||
"e400768": "🔥",
|
||||
"e400432": "👑",
|
||||
}
|
||||
|
||||
_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
|
||||
_UA_HOT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.0.0"
|
||||
)
|
||||
|
||||
_NEW_COMMENT_URL = "http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg"
|
||||
_EMOJI_RE = re.compile(r"\[em\](e\d+)\[/em\]")
|
||||
|
||||
# songmid → songId,以及并发查询合并用的 task 表
|
||||
_song_id_map: dict[str, Any] = {}
|
||||
_promises: dict[str, asyncio.Task[Any]] = {}
|
||||
|
||||
|
||||
def replace_emoji(msg: str) -> str:
|
||||
"""`[em]e400846[/em]` → emoji;未知编号替换为空串(与原文 `|| ''` 一致)。"""
|
||||
if not msg or "[em]" not in msg:
|
||||
return msg
|
||||
return _EMOJI_RE.sub(lambda match: EMOJIS.get(match.group(1), ""), msg)
|
||||
|
||||
|
||||
def format_time(time: Any) -> int | None:
|
||||
"""原文字符串长度 < 10 视为无效时间返回 null;否则补 `000` 转毫秒。"""
|
||||
text = str(time)
|
||||
if len(text) < 10:
|
||||
return None
|
||||
try:
|
||||
return int(text + "000")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
async def get_song_id(song_info: dict[str, Any]) -> Any:
|
||||
"""songId 优先;否则 songmid 反查(带缓存 + 并发合并)。"""
|
||||
song_id = song_info.get("songId")
|
||||
if song_id:
|
||||
return song_id
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
if songmid in _song_id_map:
|
||||
return _song_id_map[songmid]
|
||||
|
||||
task = _promises.get(songmid)
|
||||
if task is None:
|
||||
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
|
||||
_promises[songmid] = task
|
||||
try:
|
||||
info = await asyncio.shield(task)
|
||||
finally:
|
||||
_promises.pop(songmid, None)
|
||||
if not info:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
_song_id_map[songmid] = info.get("songId")
|
||||
return info.get("songId")
|
||||
|
||||
|
||||
def _time_pair(time: Any) -> tuple[int | None, str | None]:
|
||||
parsed = format_time(time)
|
||||
return parsed, (date_format2(parsed) if parsed else None)
|
||||
|
||||
|
||||
def filter_new_comment(raw_list: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in raw_list or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
time, time_str = _time_pair(item.get("time"))
|
||||
middle = item.get("middlecommentcontent")
|
||||
if middle:
|
||||
# 父条目头像/点赞搬到第一条楼中楼,父条目置空,楼中楼反转(照抄原文)
|
||||
first = middle[0]
|
||||
first["avatarurl"] = item.get("avatarurl")
|
||||
first["praisenum"] = item.get("praisenum")
|
||||
item["avatarurl"] = None
|
||||
item["praisenum"] = None
|
||||
middle.reverse()
|
||||
|
||||
replies: list[dict[str, Any]] = []
|
||||
for reply in middle or []:
|
||||
if not isinstance(reply, dict):
|
||||
continue
|
||||
reply_time = time if reply.get("subcommentid") == item.get("commentid") else None
|
||||
reply_time_str = time_str if reply.get("subcommentid") == item.get("commentid") else None
|
||||
replies.append(
|
||||
{
|
||||
"id": f"sub_{item.get('rootcommentid')}_{reply.get('subcommentid')}",
|
||||
"text": replace_emoji(str(reply.get("subcommentcontent") or "")).replace("\\n", "\n"),
|
||||
"time": reply_time,
|
||||
"timeStr": reply_time_str,
|
||||
"userName": str(reply.get("replynick") or "")[1:],
|
||||
"avatar": reply.get("avatarurl"),
|
||||
"userId": reply.get("encrypt_replyuin"),
|
||||
"likedCount": reply.get("praisenum"),
|
||||
}
|
||||
)
|
||||
|
||||
same_root = item.get("rootcommentid") == item.get("commentid")
|
||||
rows.append(
|
||||
{
|
||||
"id": f"{item.get('rootcommentid')}_{item.get('commentid')}",
|
||||
"rootId": item.get("rootcommentid"),
|
||||
"text": replace_emoji(str(item.get("rootcommentcontent") or "")).replace("\\n", "\n")
|
||||
if item.get("rootcommentcontent")
|
||||
else "",
|
||||
"time": time if same_root else None,
|
||||
"timeStr": time_str if same_root else None,
|
||||
"userName": str(item.get("rootcommentnick") or "")[1:],
|
||||
"avatar": item.get("avatarurl"),
|
||||
"userId": item.get("encrypt_rootcommentuin"),
|
||||
"likedCount": item.get("praisenum"),
|
||||
"reply": replies,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def filter_hot_comment(raw_list: Any) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in raw_list or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
time, time_str = _time_pair(item.get("PubTime")) if item.get("PubTime") else (None, None)
|
||||
replies: list[dict[str, Any]] = []
|
||||
for reply in item.get("SubComments") or []:
|
||||
if not isinstance(reply, dict):
|
||||
continue
|
||||
reply_time, reply_time_str = _time_pair(reply.get("PubTime")) if reply.get("PubTime") else (None, None)
|
||||
replies.append(
|
||||
{
|
||||
"id": f"sub_{reply.get('SeqNo')}_{reply.get('CmId')}",
|
||||
"text": replace_emoji(str(reply.get("Content") or "")).replace("\\n", "\n"),
|
||||
"time": reply_time,
|
||||
"timeStr": reply_time_str,
|
||||
"userName": reply.get("Nick") or "",
|
||||
"avatar": reply.get("Avatar"),
|
||||
"images": [reply["Pic"]] if reply.get("Pic") else [],
|
||||
"userId": reply.get("EncryptUin"),
|
||||
"likedCount": reply.get("PraiseNum"),
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"id": f"{item.get('SeqNo')}_{item.get('CmId')}",
|
||||
"rootId": item.get("SeqNo"),
|
||||
"text": replace_emoji(str(item.get("Content") or "")).replace("\\n", "\n")
|
||||
if item.get("Content")
|
||||
else "",
|
||||
"time": time,
|
||||
"timeStr": time_str,
|
||||
"userName": item.get("Nick") or "",
|
||||
"images": [item["Pic"]] if item.get("Pic") else [],
|
||||
"avatar": item.get("Avatar"),
|
||||
"location": item.get("Location") or "",
|
||||
"userId": item.get("EncryptUin"),
|
||||
"likedCount": item.get("PraiseNum"),
|
||||
"reply": replies,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""最新评论(fcg_global_comment_h5,form 表单)。失败文案与原文一致:`获取评论失败`。"""
|
||||
song_id = await get_song_id(song_info)
|
||||
resp = await http_fetch(
|
||||
_NEW_COMMENT_URL,
|
||||
method="POST",
|
||||
headers={"User-Agent": _UA_IE},
|
||||
form={
|
||||
"uin": "0",
|
||||
"format": "json",
|
||||
"cid": "205360772",
|
||||
"reqtype": "2",
|
||||
"biztype": "1",
|
||||
"topid": song_id,
|
||||
"cmd": "8",
|
||||
"needmusiccrit": "1",
|
||||
"pagenum": page - 1,
|
||||
"pagesize": limit,
|
||||
},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 0:
|
||||
raise RuntimeError("获取评论失败")
|
||||
comment = body.get("comment") or {}
|
||||
total = comment.get("commenttotal") or 0
|
||||
return {
|
||||
"source": "tx",
|
||||
"comments": filter_new_comment(comment.get("commentlist")),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"maxPage": math.ceil(total / limit) if limit else 1,
|
||||
}
|
||||
|
||||
|
||||
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
"""热门评论(musicu.fcg GetHotCommentList)。失败文案:`获取热门评论失败`。"""
|
||||
song_id = await get_song_id(song_info)
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="POST",
|
||||
body={
|
||||
"comm": {
|
||||
"cv": 4747474,
|
||||
"ct": 24,
|
||||
"format": "json",
|
||||
"inCharset": "utf-8",
|
||||
"outCharset": "utf-8",
|
||||
"notice": 0,
|
||||
"platform": "yqq.json",
|
||||
"needNewCode": 1,
|
||||
"uin": 0,
|
||||
},
|
||||
"req": {
|
||||
"module": "music.globalComment.CommentRead",
|
||||
"method": "GetHotCommentList",
|
||||
"param": {
|
||||
"BizType": 1,
|
||||
"BizId": str(song_id),
|
||||
"LastCommentSeqNo": "",
|
||||
"PageSize": limit,
|
||||
"PageNum": page - 1,
|
||||
"HotType": 1,
|
||||
"WithAirborne": 0,
|
||||
"PicEnable": 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers={"User-Agent": _UA_HOT, "referer": "https://y.qq.com/", "origin": "https://y.qq.com"},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
req = body.get("req") or {}
|
||||
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
|
||||
raise RuntimeError("获取热门评论失败")
|
||||
comment = (req.get("data") or {}).get("CommentList") or {}
|
||||
total = comment.get("Total") or 0
|
||||
return {
|
||||
"source": "tx",
|
||||
"comments": filter_hot_comment(comment.get("Comments")),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"maxPage": math.ceil(total / limit) if limit else 1,
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"""QQ音乐 SongInfo 组装公共片段。
|
||||
|
||||
原 TS 把这套「file.size_* → types/_types」与「专辑图/歌手图」逻辑在
|
||||
musicSearch / songList / leaderboard / singer / parse 里各抄了一份。迁移时合并到一处,
|
||||
字段与判定顺序保持原样(顺序影响前端音质下拉的排列)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
|
||||
|
||||
# 顺序即原实现的 push 顺序:(file 字段, 对外音质名)
|
||||
_SIZE_FIELDS: tuple[tuple[str, str], ...] = (
|
||||
("size_128mp3", "128k"),
|
||||
("size_320mp3", "320k"),
|
||||
("size_flac", "flac"),
|
||||
("size_hires", "flac24bit"),
|
||||
)
|
||||
|
||||
|
||||
def build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
"""file 体积字段 → (types, _types)。0/缺失表示该档不存在(与原文 `!== 0` 一致)。"""
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for key, quality in _SIZE_FIELDS:
|
||||
if file.get(key):
|
||||
size = size_formate(file[key])
|
||||
types.append({"type": quality, "size": size})
|
||||
_types[quality] = {"size": size}
|
||||
return types, _types
|
||||
|
||||
|
||||
def song_img(album_mid: Any, singers: Any) -> str:
|
||||
"""有专辑 mid 用专辑图(T002),否则退回首位歌手头像(T001)。"""
|
||||
if album_mid in ("", "空", None):
|
||||
if isinstance(singers, list) and singers and isinstance(singers[0], dict) and singers[0].get("mid"):
|
||||
return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg"
|
||||
return ""
|
||||
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg"
|
||||
|
||||
|
||||
def album_img(album_mid: Any) -> str:
|
||||
"""专辑封面(singer 专辑列表、专辑搜索用,不退化到歌手图)。"""
|
||||
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg"
|
||||
|
||||
|
||||
def music_info_item(item: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
"""原始曲目条目 → SongInfo(对应 singer.ts 的 filterMusicInfoItem)。
|
||||
|
||||
比 music_search/filter_list 的版本多带 lrc/otherSource 两个空字段 —— 原文
|
||||
只在 filterMusicInfoItem 里带,专辑/歌手曲目沿用这个形状,不要合并掉。
|
||||
"""
|
||||
raw: Mapping[str, Any] = item or {}
|
||||
file = raw.get("file") or {}
|
||||
types, _types = build_types(file)
|
||||
album = raw.get("album") or {}
|
||||
album_name = album.get("name") or ""
|
||||
album_mid = album.get("mid") or ""
|
||||
singers = raw.get("singer") or []
|
||||
return {
|
||||
"source": "tx",
|
||||
"singer": format_singer_name(singers, "name"),
|
||||
"name": raw.get("title"),
|
||||
"albumName": album_name,
|
||||
"albumId": album_mid,
|
||||
"albumMid": album_mid,
|
||||
"interval": format_play_time(raw.get("interval")),
|
||||
"songId": raw.get("id"),
|
||||
"songmid": raw.get("mid"),
|
||||
"strMediaMid": file.get("media_mid"),
|
||||
"img": song_img("" if album_name in ("", "空") else album_mid, singers),
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"""QQ音乐热搜(移植自 sdk/tx/hotSearch.ts)。
|
||||
|
||||
musicu.fcg 的 tencent_musicsoso_hotkey.HotkeyService/GetHotkeyForQQMusicPC。
|
||||
失败文案与原文一致:`获取热搜词失败`;重试上限 3 次(try max num)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.tx.utils import MUSICU_URL
|
||||
|
||||
|
||||
def filter_list(raw_list: Any) -> list[str]:
|
||||
return [str(item.get("query")) for item in raw_list or [] if isinstance(item, dict) and item.get("query")]
|
||||
|
||||
|
||||
async def get_list(retry_num: int = 0) -> dict[str, Any]:
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="post",
|
||||
headers={"Referer": "https://y.qq.com/portal/player.html"},
|
||||
body={
|
||||
"comm": {
|
||||
"ct": "19",
|
||||
"cv": "1803",
|
||||
"guid": "0",
|
||||
"patch": "118",
|
||||
"psrf_access_token_expiresAt": 0,
|
||||
"psrf_qqaccess_token": "",
|
||||
"psrf_qqopenid": "",
|
||||
"psrf_qqunionid": "",
|
||||
"tmeAppID": "qqmusic",
|
||||
"tmeLoginType": 0,
|
||||
"uin": "0",
|
||||
"wid": "0",
|
||||
},
|
||||
"hotkey": {
|
||||
"method": "GetHotkeyForQQMusicPC",
|
||||
"module": "tencent_musicsoso_hotkey.HotkeyService",
|
||||
"param": {"search_id": "", "uin": 0},
|
||||
},
|
||||
},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 0:
|
||||
raise RuntimeError("获取热搜词失败")
|
||||
return {"source": "tx", "list": filter_list(((body.get("hotkey") or {}).get("data") or {}).get("vec_hotkey"))}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""QQ音乐榜单(移植自 sdk/tx/leaderboard.ts)。
|
||||
|
||||
榜单列表是硬编码的 25 个(boardList);详情走 musicu.fcg 的
|
||||
musicToplist.ToplistInfoServer/GetDetail,period 参数(如 2026_31)从 top.html
|
||||
按正则抓取并按 bangid 缓存。抓不到就传空串 —— 接口对缺省 period 容忍,
|
||||
与原文 `info ? info.period : getPeriods(bangid)` 的降级一致。
|
||||
|
||||
上游不分页:一次返回 LIMIT 首,page 入参仅为对齐协议。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import format_play_time, format_singer_name
|
||||
from app.sdk.platforms.tx.filter import build_types, song_img
|
||||
from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU
|
||||
|
||||
LIMIT = 300
|
||||
|
||||
BOARD_LIST: list[dict[str, str]] = [
|
||||
{"id": "tx__4", "name": "流行指数榜", "bangid": "4"},
|
||||
{"id": "tx__26", "name": "热歌榜", "bangid": "26"},
|
||||
{"id": "tx__27", "name": "新歌榜", "bangid": "27"},
|
||||
{"id": "tx__62", "name": "飙升榜", "bangid": "62"},
|
||||
{"id": "tx__58", "name": "说唱榜", "bangid": "58"},
|
||||
{"id": "tx__57", "name": "喜力电音榜", "bangid": "57"},
|
||||
{"id": "tx__28", "name": "网络歌曲榜", "bangid": "28"},
|
||||
{"id": "tx__5", "name": "内地榜", "bangid": "5"},
|
||||
{"id": "tx__3", "name": "欧美榜", "bangid": "3"},
|
||||
{"id": "tx__59", "name": "香港地区榜", "bangid": "59"},
|
||||
{"id": "tx__16", "name": "韩国榜", "bangid": "16"},
|
||||
{"id": "tx__60", "name": "抖快榜", "bangid": "60"},
|
||||
{"id": "tx__29", "name": "影视金曲榜", "bangid": "29"},
|
||||
{"id": "tx__17", "name": "日本榜", "bangid": "17"},
|
||||
{"id": "tx__52", "name": "腾讯音乐人原创榜", "bangid": "52"},
|
||||
{"id": "tx__36", "name": "K歌金曲榜", "bangid": "36"},
|
||||
{"id": "tx__61", "name": "台湾地区榜", "bangid": "61"},
|
||||
{"id": "tx__63", "name": "DJ舞曲榜", "bangid": "63"},
|
||||
{"id": "tx__64", "name": "综艺新歌榜", "bangid": "64"},
|
||||
{"id": "tx__65", "name": "国风热歌榜", "bangid": "65"},
|
||||
{"id": "tx__67", "name": "听歌识曲榜", "bangid": "67"},
|
||||
{"id": "tx__72", "name": "动漫音乐榜", "bangid": "72"},
|
||||
{"id": "tx__73", "name": "游戏音乐榜", "bangid": "73"},
|
||||
{"id": "tx__75", "name": "有声榜", "bangid": "75"},
|
||||
{"id": "tx__131", "name": "校园音乐人排行榜", "bangid": "131"},
|
||||
]
|
||||
|
||||
_PERIOD_LIST_RXP = re.compile(
|
||||
r'<i class="play_cover__btn c_tx_link js_icon_play" data-listkey=".+?" data-listname=".+?" '
|
||||
r'data-tid=".+?" data-date=".+?" .+?</i>'
|
||||
)
|
||||
_PERIOD_RXP = re.compile(r'data-listname="(.+?)" data-tid=".*?\/(.+?)" data-date="(.+?)" .+?</i>')
|
||||
PERIOD_URL = "https://c.y.qq.com/node/pc/wk_v15/top.html"
|
||||
|
||||
# bangid → {"name", "bangid", "period"}(原 TS 的 periods 对象,模块级缓存)
|
||||
_periods: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
def filter_data(raw_list: Any) -> list[dict[str, Any]]:
|
||||
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 ""
|
||||
singers = item.get("singer") or []
|
||||
songs.append(
|
||||
{
|
||||
"singer": format_singer_name(singers, "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, singers),
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return songs
|
||||
|
||||
|
||||
async def _get_periods(bangid: str) -> str:
|
||||
"""抓 top.html 填缓存,返回该榜单的 period;抓不到返回空串(不抛错,接口容忍)。"""
|
||||
resp = await http_fetch(PERIOD_URL)
|
||||
html = resp.body if isinstance(resp.body, str) else ""
|
||||
for tag_html in _PERIOD_LIST_RXP.findall(html):
|
||||
result = _PERIOD_RXP.search(tag_html)
|
||||
if not result:
|
||||
continue
|
||||
_periods[result.group(2)] = {"name": result.group(1), "bangid": result.group(2), "period": result.group(3)}
|
||||
info = _periods.get(bangid)
|
||||
return info["period"] if info else ""
|
||||
|
||||
|
||||
async def _list_detail_request(bangid: str, period: str, limit: int) -> dict[str, Any]:
|
||||
# topid 必须发数字:传字符串 "4" 服务端会回 toplist.code 10006(无数据),传 4 才返回榜单。
|
||||
topid: Any = int(bangid) if str(bangid).isdigit() else bangid
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="post",
|
||||
headers={"User-Agent": UA_MUSICU},
|
||||
body={
|
||||
"toplist": {
|
||||
"module": "musicToplist.ToplistInfoServer",
|
||||
"method": "GetDetail",
|
||||
"param": {"topid": topid, "num": limit, "period": period},
|
||||
},
|
||||
"comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859},
|
||||
},
|
||||
)
|
||||
return resp.body if isinstance(resp.body, dict) else {}
|
||||
|
||||
|
||||
async def get_boards() -> dict[str, Any]:
|
||||
return {"list": BOARD_LIST, "source": "tx"}
|
||||
|
||||
|
||||
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
|
||||
retry_num += 1
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
bare = str(bangid)
|
||||
info = _periods.get(bare)
|
||||
period = info["period"] if info else await _get_periods(bare)
|
||||
|
||||
body = await _list_detail_request(bare, period, LIMIT)
|
||||
if body.get("code") != 0:
|
||||
return await get_list(bangid, page, retry_num)
|
||||
|
||||
info_list = ((body.get("toplist") or {}).get("data") or {}).get("songInfoList") or []
|
||||
return {
|
||||
"total": len(info_list),
|
||||
"list": filter_data(info_list),
|
||||
"limit": LIMIT,
|
||||
"page": 1,
|
||||
"source": "tx",
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"""QQ音乐歌词(移植自 sdk/tx/lyric.ts)。
|
||||
|
||||
上游返回 QRC(hex 密文),需 decode_qrc 解密后是逐字时间轴格式:
|
||||
[起始毫秒,持续毫秒]字(起始,持续)字(起始,持续)...
|
||||
同一份数据要拆成两路:
|
||||
lyric —— 标准 LRC(把逐字标记剥掉,只留行时间)
|
||||
yrc —— 逐字歌词(`<相对起始,持续>` 标记 + 文字)
|
||||
翻译(trans)与罗马音(roma)的对齐由 fix*_time_tag 按 ±100ms 容差匹配行时间。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
|
||||
from app.sdk.platforms.tx.qrc_decode import decode_qrc
|
||||
from app.sdk.platforms.tx.utils import MUSICU_URL
|
||||
|
||||
SUCCESS_CODE = 0
|
||||
|
||||
_UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
|
||||
|
||||
_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
|
||||
_LINE_TIME2 = re.compile(r"^\[([\d:.]+)\]")
|
||||
_WORD_TIME = re.compile(r"\(\d+,\d+\)")
|
||||
_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+\))")
|
||||
_WORD_TIME_PARTS = re.compile(r"\((\d+),(\d+)\)")
|
||||
_TIME_SPLIT = re.compile(r"[:.]")
|
||||
_TAG_HEAD = re.compile(r'^[\S\s]*?LyricContent="')
|
||||
_TAG_TAIL = re.compile(r'"\/>[\S\s]*?$')
|
||||
|
||||
# songmid → songId 缓存;promises 用于合并并发请求(同一首歌并发取歌词只查一次详情)
|
||||
_song_id_map: dict[str, Any] = {}
|
||||
_promises: dict[str, asyncio.Task[Any]] = {}
|
||||
|
||||
|
||||
def ms_format(time_ms: int) -> str:
|
||||
"""毫秒 → `[mm:ss.SSS]`。"""
|
||||
ms = time_ms % 1000
|
||||
total = time_ms // 1000
|
||||
minute = total // 60
|
||||
second = total % 60
|
||||
return f"[{minute:02d}:{second:02d}.{ms:03d}]"
|
||||
|
||||
|
||||
def parse_lyric(lrc: str) -> dict[str, str]:
|
||||
"""逐字 QRC 正文 → {lyric: 标准 LRC, yrc: 逐字歌词}。"""
|
||||
text = lrc.strip().replace("\r", "")
|
||||
if not text:
|
||||
return {"lyric": "", "yrc": ""}
|
||||
|
||||
yrc_lines: list[str] = []
|
||||
lrc_lines: list[str] = []
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
line = raw_line.strip()
|
||||
result = _LINE_TIME.search(line)
|
||||
if not result:
|
||||
if line.startswith("[offset"):
|
||||
yrc_lines.append(line)
|
||||
lrc_lines.append(line)
|
||||
if _LINE_TIME2.match(line):
|
||||
lrc_lines.append(line)
|
||||
continue
|
||||
|
||||
start_ms = int(result.group(1))
|
||||
start_str = ms_format(start_ms)
|
||||
|
||||
words = _LINE_TIME.sub("", line, count=1)
|
||||
lrc_lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
|
||||
|
||||
times = _WORD_TIME_ALL.findall(words)
|
||||
if not times:
|
||||
continue
|
||||
marks = []
|
||||
for time in times:
|
||||
parts = _WORD_TIME_PARTS.fullmatch(time)
|
||||
if parts is None:
|
||||
continue
|
||||
marks.append(f"<{max(int(parts.group(1)) - start_ms, 0)},{parts.group(2)}>")
|
||||
word_arr = _WORD_TIME.split(words)
|
||||
new_words = "".join(
|
||||
f"{mark}{word_arr[index] if index < len(word_arr) else ''}" for index, mark in enumerate(marks)
|
||||
)
|
||||
yrc_lines.append(f"{start_str}{new_words}")
|
||||
|
||||
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
|
||||
|
||||
|
||||
def parse_rlyric(lrc: str) -> str:
|
||||
"""罗马音(逐字格式)→ 标准 LRC 行。"""
|
||||
text = lrc.strip().replace("\r", "")
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
for raw_line in text.split("\n"):
|
||||
line = raw_line.strip()
|
||||
result = _LINE_TIME.search(line)
|
||||
if not result:
|
||||
continue
|
||||
start_str = ms_format(int(result.group(1)))
|
||||
words = _LINE_TIME.sub("", line, count=1)
|
||||
lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def remove_tag(text: str) -> str:
|
||||
"""剥离 `<LyricContent="..." />` 外层标签,只留正文。"""
|
||||
return _TAG_TAIL.sub("", _TAG_HEAD.sub("", text))
|
||||
|
||||
|
||||
def get_intv(interval: str) -> int:
|
||||
"""`mm:ss.SSS` / `m:ss` → 毫秒(沿用原实现的 3600000 系数,两侧比较口径一致)。"""
|
||||
if not interval:
|
||||
return 0
|
||||
if "." not in interval:
|
||||
interval += ".0"
|
||||
parts = _TIME_SPLIT.split(interval)
|
||||
while len(parts) < 3:
|
||||
parts.insert(0, "0")
|
||||
minute, second, ms = parts[0], parts[1], parts[2]
|
||||
|
||||
def _int(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return _int(minute) * 3600000 + _int(second) * 1000 + _int(ms)
|
||||
|
||||
|
||||
def _align_times(raw_lyric: str, lrc: str) -> str:
|
||||
"""把 raw_lyric 的行时间替换成 lrc 中时间最近的标签(±100ms 内才算命中)。
|
||||
|
||||
与原文一致:lrc 行是一次性指针,未命中的行被丢弃(不做回补)。
|
||||
"""
|
||||
out: list[str] = []
|
||||
lrc_lines = lrc.split("\n")
|
||||
for line in raw_lyric.split("\n"):
|
||||
result = _LINE_TIME2.match(line)
|
||||
if not result:
|
||||
continue
|
||||
if not _LINE_TIME2.sub("", line, count=1).strip():
|
||||
continue
|
||||
t1 = get_intv(result.group(1))
|
||||
|
||||
while lrc_lines:
|
||||
lrc_line = lrc_lines.pop(0)
|
||||
lrc_result = _LINE_TIME2.match(lrc_line)
|
||||
if not lrc_result:
|
||||
continue
|
||||
if abs(t1 - get_intv(lrc_result.group(1))) < 100:
|
||||
out.append(_LINE_TIME2.sub(_literal(lrc_result.group(0)), line, count=1))
|
||||
break
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _literal(text: str) -> Callable[[re.Match[str]], str]:
|
||||
r"""替换串按字面量处理(`\` 与组引用需转义;时间标签本身不带这些字符,保持严格)。"""
|
||||
return lambda _match: text
|
||||
|
||||
|
||||
def fix_rlrc_time_tag(rlrc: str, lrc: str) -> str:
|
||||
return _align_times(rlrc, lrc)
|
||||
|
||||
|
||||
def fix_tlrc_time_tag(tlrc: str, lrc: str) -> str:
|
||||
"""翻译行对齐。
|
||||
|
||||
原 TS 打算把 `mm:ss.d` 的小数位补齐到 3 位,但写的是
|
||||
`time += ''.padStart(3 - n, '0')` —— 对空串补位恒为空,实际是空操作。
|
||||
这里保持同样的换算口径(不补位),否则与 TS 版的 tlyric 对齐结果不一致。
|
||||
"""
|
||||
return _align_times(tlrc, lrc)
|
||||
|
||||
|
||||
def parse(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
|
||||
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
|
||||
if lrc:
|
||||
parsed = parse_lyric(remove_tag(lrc))
|
||||
info["lyric"] = parsed["lyric"]
|
||||
info["yrc"] = parsed["yrc"]
|
||||
if rlrc:
|
||||
info["rlyric"] = fix_rlrc_time_tag(parse_rlyric(remove_tag(rlrc)), info["lyric"])
|
||||
if tlrc:
|
||||
info["tlyric"] = fix_tlrc_time_tag(tlrc, info["lyric"])
|
||||
return info
|
||||
|
||||
|
||||
async def parse_lyric_hex(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
|
||||
"""三路 QRC hex 解密后解析。"""
|
||||
lyric, tlyric, rlyric = decode_qrc(lrc), decode_qrc(tlrc), decode_qrc(rlrc)
|
||||
return parse(lyric, tlyric, rlyric)
|
||||
|
||||
|
||||
async def get_song_id(song_info: dict[str, Any]) -> Any:
|
||||
"""songId 优先;否则用 songmid 反查(带缓存 + 并发合并)。"""
|
||||
song_id = song_info.get("songId")
|
||||
if song_id:
|
||||
return song_id
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
if not songmid:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
if songmid in _song_id_map:
|
||||
return _song_id_map[songmid]
|
||||
|
||||
task = _promises.get(songmid)
|
||||
if task is None:
|
||||
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
|
||||
_promises[songmid] = task
|
||||
try:
|
||||
info = await asyncio.shield(task)
|
||||
finally:
|
||||
_promises.pop(songmid, None)
|
||||
if not info:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
_song_id_map[songmid] = info.get("songId")
|
||||
return info.get("songId")
|
||||
|
||||
|
||||
async def _fetch(song_id: Any, retry_num: int) -> dict[str, str]:
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="post",
|
||||
headers={"referer": "https://y.qq.com", "user-agent": _UA},
|
||||
body={
|
||||
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
|
||||
"req": {
|
||||
"method": "GetPlayLyricInfo",
|
||||
"module": "music.musichallSong.PlayLyricInfo",
|
||||
"param": {
|
||||
"format": "json",
|
||||
"crypt": 1,
|
||||
"ct": 19,
|
||||
"cv": 1873,
|
||||
"interval": 0,
|
||||
"lrc_t": 0,
|
||||
"qrc": 1,
|
||||
"qrc_t": 0,
|
||||
"roma": 1,
|
||||
"roma_t": 0,
|
||||
"songID": song_id,
|
||||
"trans": 1,
|
||||
"trans_t": 0,
|
||||
"type": -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
req = body.get("req") or {}
|
||||
data = req.get("data") or {}
|
||||
if body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
|
||||
return await _retry(song_id, retry_num)
|
||||
return await parse_lyric_hex(data.get("lyric"), data.get("trans"), data.get("roma"))
|
||||
|
||||
|
||||
async def _retry(song_id: Any, retry_num: int) -> dict[str, str]:
|
||||
"""失败重试(上限 3 次,文案与原文一致:Get lyric failed)。"""
|
||||
retry_num += 1
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("Get lyric failed")
|
||||
# 原实现在此处把 songId 当 song_info 再走一次 getSongId(会解析成空 mid 而必然失败),
|
||||
# 这里直接复用已取到的 songId 重发同一请求,降级链(同接口重试)保持一致。
|
||||
return await _fetch(song_id, retry_num)
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
"""Platform.get_lyric 契约:song_info → {lyric, tlyric, rlyric, yrc}。"""
|
||||
song_id = await get_song_id(song_info)
|
||||
return await _fetch(song_id, 0)
|
||||
@@ -0,0 +1,108 @@
|
||||
"""QQ音乐歌曲信息(移植自 sdk/tx/musicInfo.ts)。
|
||||
|
||||
走 musicu.fcg 的 music.pf_song_detail_svr/get_song_detail_yqq:songmid → track_info。
|
||||
歌词与评论模块靠它把 songmid 换成数字 songID(接口只认数字 id)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import format_play_time, size_formate
|
||||
from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU
|
||||
|
||||
|
||||
def _build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
"""file.size_* → types/_types(0 表示该档不存在,与原文一致)。"""
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for key, quality in (
|
||||
("size_128mp3", "128k"),
|
||||
("size_320mp3", "320k"),
|
||||
("size_flac", "flac"),
|
||||
("size_hires", "flac24bit"),
|
||||
):
|
||||
if file.get(key):
|
||||
size = size_formate(file[key])
|
||||
types.append({"type": quality, "size": size})
|
||||
_types[quality] = {"size": size}
|
||||
return types, _types
|
||||
|
||||
|
||||
def _song_img(album_id: Any, singers: Any) -> str:
|
||||
if album_id in ("", "空", None):
|
||||
if isinstance(singers, list) and singers and singers[0].get("mid"):
|
||||
return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg"
|
||||
return ""
|
||||
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"
|
||||
|
||||
|
||||
async def get_music_info_by_mid(songmid: str) -> dict[str, Any] | None:
|
||||
"""songmid → SongInfo;无 media_mid 时返回 None(原文同样返回 null)。"""
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="post",
|
||||
headers={"User-Agent": UA_MUSICU},
|
||||
body={
|
||||
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
|
||||
"req": {
|
||||
"module": "music.pf_song_detail_svr",
|
||||
"method": "get_song_detail_yqq",
|
||||
"param": {"song_type": 0, "song_mid": songmid},
|
||||
},
|
||||
},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
req = body.get("req") or {}
|
||||
if body.get("code") != 0 or req.get("code") != 0:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
item = (req.get("data") or {}).get("track_info") or {}
|
||||
file = item.get("file") or {}
|
||||
if not file.get("media_mid"):
|
||||
return None
|
||||
|
||||
types, _types = _build_types(file)
|
||||
album = item.get("album") or {}
|
||||
album_name = album.get("name") or ""
|
||||
album_id = album.get("mid") or ""
|
||||
singers = item.get("singer") or []
|
||||
|
||||
return {
|
||||
"singer": "、".join(str(singer.get("name")) for singer in singers if isinstance(singer, dict)),
|
||||
"name": item.get("title"),
|
||||
"albumName": album_name,
|
||||
"albumId": album_id,
|
||||
"source": "tx",
|
||||
"interval": format_play_time(item.get("interval")),
|
||||
"songId": item.get("id"),
|
||||
"albumMid": album.get("mid") or "",
|
||||
"strMediaMid": file.get("media_mid"),
|
||||
"songmid": item.get("mid"),
|
||||
"img": _song_img(album_id, singers),
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
|
||||
|
||||
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Platform.get_music_info 契约:song_info 里取 songmid。"""
|
||||
songmid = song_info.get("songmid") or song_info.get("hash") or ""
|
||||
if not songmid:
|
||||
return None
|
||||
return await get_music_info_by_mid(str(songmid))
|
||||
|
||||
|
||||
async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""补全缺失的歌名/歌手/封面;取不到时原样返回(不抛错)。"""
|
||||
if song_info.get("name") and song_info.get("singer"):
|
||||
return song_info
|
||||
try:
|
||||
info = await get_music_info(song_info)
|
||||
except (RuntimeError, OSError, TimeoutError):
|
||||
return song_info
|
||||
if not info:
|
||||
return song_info
|
||||
merged = {**info, **{k: v for k, v in song_info.items() if v}}
|
||||
return merged
|
||||
@@ -0,0 +1,139 @@
|
||||
"""QQ音乐搜索(移植自 sdk/tx/musicSearch.ts)。
|
||||
|
||||
走 musics.fcg 签名通道(zzc 签名 + SearchCgiService/DoSearchForQQMusicMobile)。
|
||||
item_song → SongInfo;无 media_mid 的条目直接丢弃(原文同样 skip)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import format_play_time, format_singer_name
|
||||
from app.sdk.platforms.tx.filter import build_types, song_img
|
||||
from app.sdk.platforms.tx.utils import sign_request
|
||||
|
||||
DEFAULT_LIMIT = 50
|
||||
SUCCESS_CODE = 0
|
||||
|
||||
# 客户端指纹参数,原样照抄(少一项上游会拒)
|
||||
_SEARCH_COMM: dict[str, str] = {
|
||||
"ct": "11",
|
||||
"cv": "14090508",
|
||||
"v": "14090508",
|
||||
"tmeAppID": "qqmusic",
|
||||
"phonetype": "EBG-AN10",
|
||||
"deviceScore": "553.47",
|
||||
"devicelevel": "50",
|
||||
"newdevicelevel": "20",
|
||||
"rom": "HuaWei/EMOTION/EmotionUI_14.2.0",
|
||||
"os_ver": "12",
|
||||
"OpenUDID": "0",
|
||||
"OpenUDID2": "0",
|
||||
"QIMEI36": "0",
|
||||
"udid": "0",
|
||||
"chid": "0",
|
||||
"aid": "0",
|
||||
"oaid": "0",
|
||||
"taid": "0",
|
||||
"tid": "0",
|
||||
"wid": "0",
|
||||
"uid": "0",
|
||||
"sid": "0",
|
||||
"modeSwitch": "6",
|
||||
"teenMode": "0",
|
||||
"ui_mode": "2",
|
||||
"nettype": "1020",
|
||||
"v4ip": "",
|
||||
}
|
||||
|
||||
|
||||
def _search_id() -> str:
|
||||
"""对应原实现的 `Math.random().toString().slice(2)`:去掉 `0.` 前缀的随机数字串。"""
|
||||
return str(random.random()).split(".", 1)[-1]
|
||||
|
||||
|
||||
def handle_result(raw_list: Any) -> list[dict[str, Any]]:
|
||||
if not raw_list or not isinstance(raw_list, list):
|
||||
return []
|
||||
songs: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
file = (item or {}).get("file") or {}
|
||||
if not file.get("media_mid"):
|
||||
continue
|
||||
types, _types = build_types(file)
|
||||
album = item.get("album") or {}
|
||||
album_mid = album.get("mid") or ""
|
||||
singers = item.get("singer") or []
|
||||
songs.append(
|
||||
{
|
||||
"singer": format_singer_name(singers, "name"),
|
||||
"name": item.get("title"),
|
||||
"albumName": album.get("name") or "",
|
||||
"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(album_mid, singers),
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return songs
|
||||
|
||||
|
||||
async def _music_search(keyword: str, page: int, limit: int, retry_num: int) -> dict[str, Any]:
|
||||
"""签名通道搜索。重试上限 5 次(文案与原文一致:搜索失败)。"""
|
||||
if retry_num > 5:
|
||||
raise RuntimeError("搜索失败")
|
||||
resp = await sign_request(
|
||||
{
|
||||
"comm": _SEARCH_COMM,
|
||||
"req": {
|
||||
"module": "music.search.SearchCgiService",
|
||||
"method": "DoSearchForQQMusicMobile",
|
||||
"param": {
|
||||
"search_type": 0,
|
||||
"searchid": _search_id(),
|
||||
"query": keyword,
|
||||
"page_num": page,
|
||||
"num_per_page": limit,
|
||||
"highlight": 0,
|
||||
"nqc_flag": 0,
|
||||
"multi_zhida": 0,
|
||||
"cat": 2,
|
||||
"grp": 1,
|
||||
"sin": 0,
|
||||
"sem": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
req = body.get("req") or {}
|
||||
if not req or body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
|
||||
return await _music_search(keyword, page, limit, retry_num + 1)
|
||||
return req.get("data") or {}
|
||||
|
||||
|
||||
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
|
||||
if not limit:
|
||||
limit = DEFAULT_LIMIT
|
||||
data = await _music_search(keyword, page, limit, retry_num)
|
||||
# 响应结构:req.data.{body, meta}。歌曲数组在 data.body.item_song,总数在 data.meta.estimate_sum
|
||||
# (TS 侧写的是 body.item_song / meta.estimate_sum,其中 body 即此处的 data)。
|
||||
inner = data.get("body")
|
||||
meta = data.get("meta")
|
||||
songs = handle_result(inner.get("item_song") if isinstance(inner, dict) else None)
|
||||
total = (meta.get("estimate_sum") or 0) if isinstance(meta, dict) else 0
|
||||
return {
|
||||
"list": songs,
|
||||
"allPage": -(-int(total) // limit) if total else 0, # ceil 除法
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"source": "tx",
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"""QQ音乐链接解析(移植自 sdk/tx/parse.ts)。
|
||||
|
||||
歌曲链接形态:
|
||||
* https://y.qq.com/n/ryqq/songDetail/<songmid>
|
||||
* https://y.qq.com/n/yqq/song/<songmid>.html
|
||||
* https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
|
||||
* ...?songmid=<songmid>
|
||||
|
||||
元数据走 fcg_play_single_song(顺带拿 file 体积信息)。直链 url/type 是尽力附带:
|
||||
取流失败只返回元数据,不影响解析结果(与上游一致,VIP/下架曲目即如此)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 format_play_time, format_singer_name, size_formate
|
||||
from app.sdk.platforms.tx.filter import album_img
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
|
||||
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
|
||||
)
|
||||
|
||||
_SONG_DETAIL_RE = re.compile(r"songDetail/([A-Za-z0-9]+)")
|
||||
_SONG_PATH_RE = re.compile(r"/song/([A-Za-z0-9]+)")
|
||||
_SONG_MID_QUERY_RE = re.compile(r"[?&]songmid=([A-Za-z0-9]+)")
|
||||
_SONG_ID_QUERY_RE = re.compile(r"[?&]songid=(\d+)")
|
||||
|
||||
_DETAIL_URL = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg"
|
||||
|
||||
|
||||
async def _fetch_single_song(query: str) -> dict[str, Any] | None:
|
||||
"""fcg_play_single_song 首条(query 形如 songmid=xxx / songid=123)。"""
|
||||
resp = await http_fetch(
|
||||
f"{_DETAIL_URL}?{query}&format=json",
|
||||
headers={"User-Agent": _UA, "Referer": "http://m.y.qq.com"},
|
||||
timeout=15000,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
data = body.get("data")
|
||||
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
||||
return None
|
||||
return data[0]
|
||||
|
||||
|
||||
async def fetch_mid_by_song_id(songid: str) -> str:
|
||||
"""上游 fetchSongDetailByID:数字 songid → mid。"""
|
||||
item = await _fetch_single_song(f"songid={quote(str(songid), safe='')}")
|
||||
mid = str((item or {}).get("mid") or "")
|
||||
if not mid:
|
||||
raise RuntimeError("QQ音乐歌曲不存在")
|
||||
return mid
|
||||
|
||||
|
||||
async def fetch_song_detail_raw(songmid: str) -> dict[str, Any]:
|
||||
"""上游 fetchSongDetail:songmid → 原始条目。"""
|
||||
item = await _fetch_single_song(f"songmid={quote(str(songmid), safe='')}")
|
||||
if not item or not item.get("mid"):
|
||||
raise RuntimeError("QQ音乐歌曲不存在")
|
||||
return item
|
||||
|
||||
|
||||
def convert_song(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""原始条目 → MusicServer Song 形状。
|
||||
|
||||
注意体积字段的判定与 filter.build_types 不同:这里要求 `isinstance(number)` 且 > 0
|
||||
(原文是 `typeof size === 'number' && size > 0`),因此字符串型体积会被跳过。
|
||||
"""
|
||||
file = item.get("file") or {}
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for key, quality in (
|
||||
("size_128mp3", "128k"),
|
||||
("size_320mp3", "320k"),
|
||||
("size_flac", "flac"),
|
||||
("size_hires", "flac24bit"),
|
||||
):
|
||||
size = file.get(key)
|
||||
if isinstance(size, (int, float)) and not isinstance(size, bool) and size > 0:
|
||||
formatted = size_formate(size)
|
||||
types.append({"type": quality, "size": formatted})
|
||||
_types[quality] = {"size": formatted}
|
||||
|
||||
album = item.get("album") or {}
|
||||
album_mid = album.get("mid") or ""
|
||||
return {
|
||||
"source": "tx",
|
||||
"songmid": str(item.get("mid")),
|
||||
"songId": item.get("id"),
|
||||
"name": item.get("title") or item.get("name") or "",
|
||||
"singer": format_singer_name(item.get("singer"), "name"),
|
||||
"albumId": album_mid,
|
||||
"albumMid": album_mid,
|
||||
"albumName": album.get("name") or "",
|
||||
"interval": format_play_time(item.get("interval") or 0),
|
||||
"img": album_img(album_mid) if album_mid else "",
|
||||
"lrc": None,
|
||||
"strMediaMid": file.get("media_mid") or item.get("mid"),
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
|
||||
|
||||
async def parse_song(link: str) -> dict[str, Any]:
|
||||
"""歌曲链接 → Song(尽力附带直链 url/type)。"""
|
||||
raw = str(link or "")
|
||||
match = _SONG_DETAIL_RE.search(raw) or _SONG_PATH_RE.search(raw)
|
||||
songmid = match.group(1) if match else ""
|
||||
songid = ""
|
||||
if not songmid:
|
||||
id_match = _SONG_ID_QUERY_RE.search(raw)
|
||||
songid = id_match.group(1) if id_match else ""
|
||||
mid_match = _SONG_MID_QUERY_RE.search(raw)
|
||||
songmid = mid_match.group(1) if mid_match else ""
|
||||
if not songmid and songid:
|
||||
songmid = await fetch_mid_by_song_id(songid)
|
||||
if not songmid:
|
||||
raise RuntimeError("无效的QQ音乐歌曲链接")
|
||||
|
||||
item = await fetch_song_detail_raw(songmid)
|
||||
song = convert_song(item)
|
||||
if songid:
|
||||
song["songId"] = int(songid)
|
||||
try:
|
||||
# 延迟导入:apis.tx 反向依赖 platforms.tx.utils,模块级互 import 会拿到半初始化模块
|
||||
from app.sdk.apis import tx as tx_api
|
||||
|
||||
hit = await tx_api.get_music_url(song, "320k")
|
||||
song["url"] = hit.get("url")
|
||||
song["type"] = hit.get("type")
|
||||
except (RuntimeError, OSError, TimeoutError):
|
||||
# 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
pass
|
||||
return song
|
||||
|
||||
|
||||
async def parse_album(link: str) -> dict[str, Any]:
|
||||
from app.sdk.platforms.tx.album import parse_album_link
|
||||
|
||||
return await parse_album_link(link)
|
||||
|
||||
|
||||
async def parse_playlist(link: str) -> dict[str, Any]:
|
||||
from app.sdk.platforms.tx.playlist import parse_playlist_link
|
||||
|
||||
return await parse_playlist_link(link)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""QQ音乐封面(移植自 sdk/tx/index.ts 的 getPic)。
|
||||
|
||||
封面是固定 URL 规则:T002 = 专辑图,按 albumId/albumMid 拼。路由总是传 albumId(缺省空串),
|
||||
这里对 None 也按空串处理(原 JS 会把 undefined 拼成字面量 "undefined",无实际意义)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
async def get_pic(song_info: dict[str, Any]) -> str:
|
||||
album_id = song_info.get("albumId") or ""
|
||||
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"
|
||||
@@ -0,0 +1,299 @@
|
||||
"""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
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,147 @@
|
||||
"""QQ音乐歌手(移植自 sdk/tx/singer.ts)。
|
||||
|
||||
上游歌手详情接口(musichallSinger.GetSingerDetail 等)已停用(104403),这里按原文的
|
||||
合成路径取信息:
|
||||
* 歌手名 —— 从 GetSingerSongList 第一条曲目的 singer 数组里按 mid 反查(找不到取首位)
|
||||
* 作品数 —— GetSingerSongList 的 totalNum
|
||||
* 专辑数 —— GetAlbumList 的 total
|
||||
* 头像/性别 —— 头像走固定 URL 规则(T001),性别接口已无可信来源,返回 null
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.tx.filter import album_img, music_info_item
|
||||
from app.sdk.platforms.tx.utils import create_musicu_fetch
|
||||
|
||||
SONG_LIST_MODULE = "musichall.song_list_server"
|
||||
ALBUM_LIST_MODULE = "music.musichallAlbum.AlbumListServer"
|
||||
|
||||
|
||||
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
|
||||
"""GetSingerSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。"""
|
||||
return [music_info_item((item or {}).get("songInfo")) for item in raw or []]
|
||||
|
||||
|
||||
def filter_album_list(raw: Any) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": item.get("albumID"),
|
||||
"mid": item.get("albumMid"),
|
||||
"count": item.get("totalNum"),
|
||||
"info": {
|
||||
"name": item.get("albumName"),
|
||||
"author": item.get("singerName"),
|
||||
"img": album_img(item.get("albumMid")),
|
||||
"desc": None,
|
||||
},
|
||||
}
|
||||
for item in raw or []
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
|
||||
|
||||
async def get_info(id: str) -> dict[str, Any]:
|
||||
"""歌手信息:歌曲数与专辑数并发取,任一失败整体失败(与原文 Promise.all 一致)。"""
|
||||
song_body, album_body = await asyncio.gather(
|
||||
create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": SONG_LIST_MODULE,
|
||||
"method": "GetSingerSongList",
|
||||
"param": {"singerMid": id, "order": 1, "begin": 0, "num": 1},
|
||||
}
|
||||
}
|
||||
),
|
||||
create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": ALBUM_LIST_MODULE,
|
||||
"method": "GetAlbumList",
|
||||
"param": {"singerMid": id, "order": 0, "begin": 0, "num": 1, "songNumTag": 0, "singerID": 0},
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
song_req = song_body.get("req") or {}
|
||||
album_req = album_body.get("req") or {}
|
||||
if song_req.get("code") != 0 or album_req.get("code") != 0:
|
||||
raise RuntimeError("get singer info faild.")
|
||||
|
||||
song_data = song_req.get("data") or {}
|
||||
album_data = album_req.get("data") or {}
|
||||
# 详情接口停用:歌手名从歌曲条目反查
|
||||
first_song = (song_data.get("songList") or [{}])[0] or {}
|
||||
singers = (first_song.get("songInfo") or {}).get("singer") or []
|
||||
self_info = next((s for s in singers if isinstance(s, dict) and s.get("mid") == id), None)
|
||||
if self_info is None:
|
||||
self_info = singers[0] if singers and isinstance(singers[0], dict) else {}
|
||||
return {
|
||||
"source": "tx",
|
||||
"id": id,
|
||||
"info": {
|
||||
"name": self_info.get("name") or "",
|
||||
"desc": None,
|
||||
"avatar": f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{id}.jpg",
|
||||
"gender": None,
|
||||
},
|
||||
"count": {
|
||||
"music": song_data.get("totalNum"),
|
||||
"album": album_data.get("total"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
body = await create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": SONG_LIST_MODULE,
|
||||
"method": "GetSingerSongList",
|
||||
"param": {"singerMid": id, "order": 1, "begin": (page - 1) * limit, "num": limit},
|
||||
}
|
||||
}
|
||||
)
|
||||
req = body.get("req") or {}
|
||||
if req.get("code") != 0:
|
||||
raise RuntimeError("get singer song list faild.")
|
||||
data = req.get("data") or {}
|
||||
return {
|
||||
"source": "tx",
|
||||
"list": filter_song_list(data.get("songList")),
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"total": data.get("totalNum"),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
body = await create_musicu_fetch(
|
||||
{
|
||||
"req": {
|
||||
"module": ALBUM_LIST_MODULE,
|
||||
"method": "GetAlbumList",
|
||||
"param": {
|
||||
"singerMid": id,
|
||||
"order": 0,
|
||||
"begin": (page - 1) * limit,
|
||||
"num": limit,
|
||||
"songNumTag": 0,
|
||||
"singerID": 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
req = body.get("req") or {}
|
||||
if req.get("code") != 0:
|
||||
raise RuntimeError("get singer album faild.")
|
||||
data = req.get("data") or {}
|
||||
return {
|
||||
"source": "tx",
|
||||
"list": filter_album_list(data.get("albumList")),
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"total": data.get("total"),
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
"""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¬ice=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¬ice=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
|
||||
@@ -0,0 +1,292 @@
|
||||
"""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"}
|
||||
@@ -0,0 +1,63 @@
|
||||
"""QQ音乐(tx)工具集 —— 签名通道与 zzc 签名。
|
||||
|
||||
源: sdk/tx/utils/index.ts + sdk/tx/utils/crypto.ts
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.tx.utils.crypto import zzc_sign
|
||||
|
||||
MUSICU_URL = "https://u.y.qq.com/cgi-bin/musicu.fcg"
|
||||
# 原 TS 用两条不同 UA:musics.fcg 走客户端 UA,musicu.fcg 走旧 IE UA
|
||||
UA_CLIENT = "QQMusic 14090508(android 12)"
|
||||
UA_MUSICU = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
|
||||
|
||||
|
||||
async def sign_request(data: dict[str, Any]) -> Any:
|
||||
"""musics.fcg 签名通道:sign 由 zzc_sign(JSON 串) 生成。
|
||||
|
||||
签名与实际发送的 body 必须是同一个字符串:http_fetch 对 dict 会自行
|
||||
json.dumps(默认分隔符带空格),与这里的紧凑序列化不一致会导致服务端验签失败并回
|
||||
code 2000。因此统一序列化一次,并把该字符串原样作为 body 发送(str 走原样发送分支)。
|
||||
"""
|
||||
text = _to_json(data)
|
||||
sign = zzc_sign(text)
|
||||
return await http_fetch(
|
||||
f"https://u.y.qq.com/cgi-bin/musics.fcg?sign={sign}",
|
||||
method="post",
|
||||
headers={"User-Agent": UA_CLIENT},
|
||||
body=text,
|
||||
)
|
||||
|
||||
|
||||
async def create_musicu_fetch(data: dict[str, Any], retry_num: int = 0) -> dict[str, Any]:
|
||||
"""musicu.fcg 通用请求,失败重试 3 次(文案与原文一致:try max num)。
|
||||
|
||||
注意:comm 固定 uin=0,即便已登录也走匿名通道 —— 与原文一致(登录态只影响取流)。
|
||||
"""
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
try:
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="post",
|
||||
headers={"User-Agent": UA_MUSICU},
|
||||
body={"comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859}, **data},
|
||||
)
|
||||
except (RuntimeError, OSError, TimeoutError):
|
||||
return await create_musicu_fetch(data, retry_num + 1)
|
||||
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("code") != 0:
|
||||
return await create_musicu_fetch(data, retry_num + 1)
|
||||
return body
|
||||
|
||||
|
||||
def _to_json(data: Any) -> str:
|
||||
import json
|
||||
|
||||
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
|
||||
@@ -0,0 +1,56 @@
|
||||
"""QQ音乐 musics.fcg 签名(移植自 sdk/tx/utils/crypto.ts)。
|
||||
|
||||
算法:SHA1 摘要的十六进制串 → 按固定下标取两段字符 → 第三段用固定混淆表
|
||||
与摘要每字节异或后 base64(剔除 + / = 与反斜杠)→ 拼成 `zzc{part1}{b64}{part2}` 转小写。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
PART_1_INDEXES = (23, 14, 6, 36, 16, 40, 7, 19)
|
||||
PART_2_INDEXES = (16, 1, 32, 12, 19, 27, 8, 5)
|
||||
SCRAMBLE_VALUES = (
|
||||
89,
|
||||
39,
|
||||
179,
|
||||
150,
|
||||
218,
|
||||
82,
|
||||
58,
|
||||
252,
|
||||
177,
|
||||
52,
|
||||
186,
|
||||
123,
|
||||
120,
|
||||
64,
|
||||
242,
|
||||
133,
|
||||
143,
|
||||
161,
|
||||
121,
|
||||
179,
|
||||
)
|
||||
_B64_STRIP = re.compile(r"[\\/+=]")
|
||||
|
||||
|
||||
def _pick_by_indexes(hash_hex: str, indexes: tuple[int, ...]) -> str:
|
||||
"""按固定下标取字符。
|
||||
|
||||
PART_1_INDEXES 含下标 40,而 SHA1 十六进制串正好 40 字符(合法下标 0–39)。
|
||||
TS 侧 `hash[40]` 取到 undefined,`join('')` 会把它并成空串;Python 直接下标会抛
|
||||
IndexError,因此这里显式跳过越界下标,保持与上游签名逐字节一致。
|
||||
"""
|
||||
return "".join(hash_hex[index] for index in indexes if 0 <= index < len(hash_hex))
|
||||
|
||||
|
||||
def zzc_sign(text: str) -> str:
|
||||
hash_hex = hashlib.sha1(text.encode()).hexdigest()
|
||||
part1 = _pick_by_indexes(hash_hex, PART_1_INDEXES)
|
||||
part2 = _pick_by_indexes(hash_hex, PART_2_INDEXES)
|
||||
scrambled = bytes(value ^ int(hash_hex[i * 2 : i * 2 + 2], 16) for i, value in enumerate(SCRAMBLE_VALUES))
|
||||
part3 = _B64_STRIP.sub("", base64.b64encode(scrambled).decode())
|
||||
return f"zzc{part1}{part3}{part2}".lower()
|
||||
Reference in new issue
Block a user