- 新增 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 路由验证通过
204 lines
7.7 KiB
Python
204 lines
7.7 KiB
Python
"""千千音乐专辑(对应 sdk/bd/album.ts)。
|
||
|
||
搜索:`/v1/search?type=3`(特殊字符触发 errno 23001 时用清洗后的关键词重试)。
|
||
详情:`/v1/album/info?albumAssetCode=P...`(P 码可直接用;纯数字旧 id 走
|
||
`/v1/album/albumid2psid` 转换)。
|
||
专辑链接:`https://music.91q.com/album/<albumAssetCode>`。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.sdk.platforms.bd.util import (
|
||
APP_ID,
|
||
album_link,
|
||
api_get,
|
||
api_ok,
|
||
error_text,
|
||
join_artists,
|
||
map_track,
|
||
normalize_album_asset_code,
|
||
release_date10,
|
||
sanitize_album_keyword,
|
||
to_number,
|
||
)
|
||
|
||
|
||
class _RetryableAlbumError(RuntimeError):
|
||
"""专辑搜索失败;`retry_with_sanitized` 表示 23001(词非法)可换清洗后的词重试。"""
|
||
|
||
def __init__(self, message: str, retry_with_sanitized: bool) -> None:
|
||
super().__init__(message)
|
||
self.retry_with_sanitized = retry_with_sanitized
|
||
|
||
|
||
def _map_album_summary(item: Any) -> dict[str, Any]:
|
||
source = item if isinstance(item, dict) else {}
|
||
id = normalize_album_asset_code(source.get("albumAssetCode"))
|
||
track_list = source.get("trackList")
|
||
return {
|
||
"id": id,
|
||
"name": str(source.get("title") or "").strip(),
|
||
"cover": str(source.get("pic") or "").strip(),
|
||
"trackCount": len(track_list) if isinstance(track_list, list) else 0,
|
||
"creator": join_artists(source.get("artist")),
|
||
"description": str(source.get("introduce") or "").strip(),
|
||
"releaseDate": release_date10(source.get("releaseDate")),
|
||
"genre": str(source.get("genre") or "").strip(),
|
||
"lang": str(source.get("lang") or "").strip(),
|
||
"link": album_link(id) if id else "",
|
||
"source": "bd",
|
||
}
|
||
|
||
|
||
async def _search_album_once(word: str, page: int = 1, limit: int = 10) -> list[Any]:
|
||
body = await api_get(
|
||
"/v1/search",
|
||
{
|
||
"word": word,
|
||
"type": "3",
|
||
"pageNo": str(max(1, page)),
|
||
"pageSize": str(max(1, limit)),
|
||
"appid": APP_ID,
|
||
},
|
||
)
|
||
if not api_ok(body):
|
||
errno = body.get("errno") if isinstance(body, dict) else None
|
||
raise _RetryableAlbumError(error_text(body, "千千专辑搜索失败"), to_number(errno) == 23001)
|
||
data = (body.get("data") if isinstance(body, dict) else None) or {}
|
||
items = data.get("typeAlbum") if isinstance(data, dict) else None
|
||
return list(items) if isinstance(items, list) else []
|
||
|
||
|
||
async def search_album_items(keyword: str, page: int = 1, limit: int = 10) -> list[Any]:
|
||
"""上游 searchAlbumItems:原词失败(23001)时用清洗后的关键词重试一次。"""
|
||
words = [str(keyword if keyword is not None else "").strip()]
|
||
sanitized = sanitize_album_keyword(keyword)
|
||
if sanitized and sanitized != words[0]:
|
||
words.append(sanitized)
|
||
|
||
last_err: Exception | None = None
|
||
for word in words:
|
||
try:
|
||
return await _search_album_once(word, page, limit)
|
||
except _RetryableAlbumError as err:
|
||
last_err = err
|
||
# 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
|
||
if not err.retry_with_sanitized:
|
||
break
|
||
if last_err is not None:
|
||
raise last_err
|
||
return []
|
||
|
||
|
||
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||
"""搜索专辑(返回歌单/专辑统一摘要形状)。"""
|
||
word = str(keyword if keyword is not None else "").strip()
|
||
if not word:
|
||
return {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
|
||
|
||
items = await search_album_items(word, page, limit)
|
||
list_out = [album for album in (_map_album_summary(item) for item in items) if album["id"]][:limit]
|
||
return {"source": "bd", "list": list_out, "total": len(items), "page": page, "limit": limit}
|
||
|
||
|
||
async def resolve_album_asset_code(id: str) -> str:
|
||
"""旧数字专辑 id → PSID(P 码);已是 P 码则原样返回。"""
|
||
raw = str(id if id is not None else "").strip()
|
||
if not raw:
|
||
raise RuntimeError("千千专辑 id 为空")
|
||
|
||
normalized = normalize_album_asset_code(raw)
|
||
if normalized:
|
||
return normalized
|
||
|
||
body = await api_get("/v1/album/albumid2psid", {"albumid": raw, "appid": APP_ID})
|
||
if not api_ok(body):
|
||
raise RuntimeError(error_text(body, "千千专辑 id 转换失败"))
|
||
data = body.get("data") if isinstance(body, dict) else None
|
||
for item in data if isinstance(data, list) else []:
|
||
if isinstance(item, dict):
|
||
psid = normalize_album_asset_code(item.get("psid"))
|
||
if psid:
|
||
return psid
|
||
raise RuntimeError("千千专辑 asset code 未找到")
|
||
|
||
|
||
async def _fetch_album_info(id: str) -> tuple[str, dict[str, Any]]:
|
||
album_id = await resolve_album_asset_code(id)
|
||
body = await api_get("/v1/album/info", {"albumAssetCode": album_id, "appid": APP_ID})
|
||
if not api_ok(body):
|
||
raise RuntimeError(error_text(body, "千千专辑详情获取失败"))
|
||
data = (body.get("data") if isinstance(body, dict) else None) or {}
|
||
if not normalize_album_asset_code(data.get("albumAssetCode") if isinstance(data, dict) else None):
|
||
raise RuntimeError("千千专辑不存在或已下架")
|
||
return album_id, data
|
||
|
||
|
||
async def get_album_detail(id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
|
||
"""专辑详情(统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片)。"""
|
||
album_id, data = await _fetch_album_info(id)
|
||
title = str(data.get("title") or "").strip()
|
||
cover = str(data.get("pic") or "").strip()
|
||
creator = join_artists(data.get("artist"))
|
||
all_tracks = data.get("trackList")
|
||
tracks = list(all_tracks) if isinstance(all_tracks, list) else []
|
||
|
||
songs: list[dict[str, Any]] = []
|
||
for item in tracks:
|
||
song = map_track(item, album_id=album_id, album_name=title, img=cover, singer=creator)
|
||
if not song["songmid"]:
|
||
continue
|
||
# 专辑内曲目补上序号(上游 Extra.track / sort);为 0 时不写该键,与 TS 的 undefined 一致
|
||
sort = to_number(item.get("sort") if isinstance(item, dict) else None)
|
||
if sort > 0:
|
||
song["track"] = int(sort)
|
||
songs.append(song)
|
||
|
||
size = max(1, limit)
|
||
start = (max(1, page) - 1) * size
|
||
|
||
return {
|
||
"source": "bd",
|
||
"info": {
|
||
"id": album_id,
|
||
"name": title,
|
||
"img": cover,
|
||
"desc": str(data.get("introduce") or "").strip(),
|
||
"author": creator,
|
||
"releaseDate": release_date10(data.get("releaseDate")),
|
||
"genre": str(data.get("genre") or "").strip(),
|
||
"lang": str(data.get("lang") or "").strip(),
|
||
"total": len(songs),
|
||
},
|
||
"list": songs[start : start + size],
|
||
"page": page,
|
||
"limit": limit,
|
||
"total": len(songs),
|
||
"allPage": math.ceil(len(songs) / size) or 1,
|
||
}
|
||
|
||
|
||
async def get_album_songs(id: str) -> list[dict[str, Any]]:
|
||
"""上游 GetAlbumSongs:只取曲目列表。"""
|
||
detail = await get_album_detail(id, 1, 10000)
|
||
return detail["list"]
|
||
|
||
|
||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||
"""上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态。"""
|
||
raw = str(link if link is not None else "").strip()
|
||
patterns = (
|
||
re.compile(r"music\.91q\.com/album/([A-Za-z0-9]+)"),
|
||
re.compile(r"albumAssetCode=([A-Za-z0-9]+)"),
|
||
re.compile(r"albumid=(\d+)"),
|
||
)
|
||
for pattern in patterns:
|
||
match = pattern.search(raw)
|
||
if match:
|
||
return await get_album_detail(match.group(1))
|
||
raise RuntimeError("无效的千千专辑链接")
|