Files
MusicServer/app/sdk/platforms/tx/album.py
T
snowy-yang dfc06bf4de feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复
- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
2026-10-23 21:55:45 +08:00

184 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""QQ音乐专辑(移植自 sdk/tx/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音乐专辑链接")