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,96 @@
|
||||
"""千千音乐(music.91q.com)平台模块(对应 sdk/bd/index.ts)。
|
||||
|
||||
上游 music-lib/qianqian 的移植。匿名可用面(实测):
|
||||
|
||||
* 搜索(type=1)、专辑搜索 + 详情(type=3、album/info);
|
||||
* 歌单详情 + 分类 + 分类歌单;
|
||||
* 歌词(song/info 返回 txt URL);
|
||||
* 免费曲目 128k/320k/flac 明文直链(tracklink 签名通道)。
|
||||
|
||||
限制(与上游一致,不造假数据):
|
||||
|
||||
* VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 `payPlay`;
|
||||
* 歌单关键词搜索(`/v1/search?type=6`)服务端已下线(errno 23001),`search_playlist` 返回空列表;
|
||||
* 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 `user_playlist`;
|
||||
* 千千无独立封面接口:搜索结果/专辑/歌单自带 pic,缺失时回退 `song/info`;
|
||||
* 千千无「榜单」概念:`song_list` 以歌单分类作为标签体系(tagId 为 `/v1/tracklist/category` 的 subCate id)。
|
||||
|
||||
凭据:cookie 从 `app/sdk/auth_store.py` 的 `get_credentials('bd')` 读取(可选,仅影响 VIP 等登录态资源)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms._base import Platform
|
||||
from app.sdk.platforms.bd import album, lyric, music_info, music_search, parse, playlist
|
||||
from app.sdk.platforms.bd.util import get_qian_cookie
|
||||
|
||||
|
||||
class _SongList:
|
||||
"""MusicServer 的 songList 契约:`get_list(sort_id, tag_id, page)` / `get_tags()`。
|
||||
|
||||
千千把歌单分类当标签体系,`tagId` 为 subCate id(纯数字才下发,其它回落到「全部」)。
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
|
||||
cate = str(tag_id or sort_id or "").strip()
|
||||
return await playlist.get_category_playlists(cate if re.fullmatch(r"\d+", cate) else "", page, 30)
|
||||
|
||||
@staticmethod
|
||||
async def get_tags() -> dict[str, Any]:
|
||||
return await playlist.get_categories()
|
||||
|
||||
|
||||
song_list = _SongList()
|
||||
|
||||
|
||||
async def get_pic(song_info: dict[str, Any]) -> str | None:
|
||||
"""千千无独立封面接口:先用曲目自带的 img,缺失时回退 song/info。"""
|
||||
img = song_info.get("img")
|
||||
if img:
|
||||
return str(img)
|
||||
try:
|
||||
info = await music_info.get_song_info(str(song_info.get("songmid") or ""))
|
||||
return info.get("img") or None
|
||||
except Exception: # noqa: BLE001 - 与原文一致:封面取不到就返回 null,不影响调用方
|
||||
return None
|
||||
|
||||
|
||||
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
|
||||
return await music_info.get_song_info(str(song_info.get("songmid") or ""))
|
||||
|
||||
|
||||
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
|
||||
tsid = str(song_info.get("songmid") or "").strip()
|
||||
return f"https://music.91q.com/song/{tsid}" if tsid else "https://music.91q.com/"
|
||||
|
||||
|
||||
def status() -> dict[str, Any]:
|
||||
"""凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流)。"""
|
||||
return {"logged": True} if get_qian_cookie() else {"logged": False}
|
||||
|
||||
|
||||
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""延迟导入取流实现:`apis/bd.py` 反过来依赖本包的 util,顶层导入会形成环。"""
|
||||
from app.sdk.apis.bd import get_music_url
|
||||
|
||||
return await get_music_url(song_info, quality, retry_num)
|
||||
|
||||
|
||||
MODULE = Platform(
|
||||
id="bd",
|
||||
name="千千音乐",
|
||||
music_search=music_search,
|
||||
song_list=song_list,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
parse=parse,
|
||||
get_music_url=_get_music_url,
|
||||
get_lyric=lyric.get_lyric,
|
||||
get_pic=get_pic,
|
||||
get_music_info=get_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""千千音乐专辑(对应 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("无效的千千专辑链接")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""千千音乐歌词(对应 sdk/bd/lyric.ts)。
|
||||
|
||||
链路:`/v1/song/info` → `data[0].lyric` 为歌词文本 URL(static-qianqian.taihe.com/*.txt)
|
||||
→ 直接下载文本。返回 `{lyric, tlyric: ''}`(千千只有原文歌词,无翻译)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.bd.music_info import get_song_info
|
||||
from app.sdk.platforms.bd.util import request_headers
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, Any]:
|
||||
tsid = str(song_info.get("songmid") or song_info.get("TSID") or "").strip()
|
||||
if not tsid:
|
||||
raise RuntimeError("千千歌词需要 songmid(TSID)")
|
||||
|
||||
info = await get_song_info(tsid)
|
||||
lyric_url = str(info.get("lyricUrl") or "").strip()
|
||||
if not lyric_url:
|
||||
raise RuntimeError("千千该歌曲暂无歌词")
|
||||
|
||||
resp = await http_fetch(lyric_url, method="get", headers=request_headers(), timeout=15000)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"千千歌词下载失败(HTTP {resp.status_code})")
|
||||
lyric = resp.body if isinstance(resp.body, str) else resp.raw.decode("utf-8", errors="replace")
|
||||
return {"lyric": lyric, "tlyric": ""}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""千千音乐单曲信息(对应 sdk/bd/musicInfo.ts)。
|
||||
|
||||
接口返回 `data[]`:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track
|
||||
|
||||
|
||||
async def get_song_info(tsid: str) -> dict[str, Any]:
|
||||
"""由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl)。"""
|
||||
id = str(tsid if tsid is not None else "").strip()
|
||||
if not id:
|
||||
raise RuntimeError("千千歌曲 id(TSID)为空")
|
||||
body = await api_get("/v1/song/info", {"TSID": id, "appid": APP_ID})
|
||||
data = body.get("data") if isinstance(body, dict) else None
|
||||
if not api_ok(body) and not isinstance(data, list):
|
||||
raise RuntimeError(error_text(body, "千千歌曲信息获取失败"))
|
||||
|
||||
item = data[0] if isinstance(data, list) and data else None
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError("千千歌曲不存在或已下架")
|
||||
|
||||
song = map_track(item)
|
||||
return {
|
||||
**song,
|
||||
# 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词)
|
||||
"songmid": song["songmid"] or id,
|
||||
"lyricUrl": str(item.get("lyric") or "").strip(),
|
||||
"raw": item,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""千千音乐搜索(对应 sdk/bd/musicSearch.ts)。
|
||||
|
||||
`/v1/search?word=&type=1&pageNo=&pageSize=`,appid + 签名。
|
||||
响应 `data.typeTrack[]`:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip。
|
||||
|
||||
注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay,
|
||||
以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.bd.util import APP_ID, api_get, api_ok, error_text, map_track, to_int, to_number
|
||||
|
||||
|
||||
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
|
||||
word = str(keyword if keyword is not None else "").strip()
|
||||
if not word:
|
||||
return {"list": [], "allPage": 1, "total": 0, "limit": limit, "source": "bd"}
|
||||
|
||||
body = await api_get(
|
||||
"/v1/search",
|
||||
{"word": word, "type": "1", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
|
||||
)
|
||||
# 23001 = 服务端不认该搜索类型/关键词,与原文一致:按空结果处理而不是报错
|
||||
if not api_ok(body) and to_number(body.get("errno") if isinstance(body, dict) else None) != 23001:
|
||||
raise RuntimeError(error_text(body, "千千搜索失败"))
|
||||
|
||||
data = (body.get("data") if isinstance(body, dict) else None) or {}
|
||||
tracks = data.get("typeTrack") if isinstance(data, dict) else None
|
||||
raw = tracks if isinstance(tracks, list) else []
|
||||
total = to_int(data.get("total") if isinstance(data, dict) else None, len(raw))
|
||||
have_more = to_number(data.get("haveMore") if isinstance(data, dict) else None) == 1
|
||||
|
||||
songs = [song for song in (map_track(item) for item in raw) if song["songmid"]]
|
||||
page_size = max(1, limit)
|
||||
all_page = page + 1 if have_more else max(page, math.ceil(total / page_size) or 1)
|
||||
|
||||
return {"list": songs, "allPage": all_page, "total": total, "limit": limit, "source": "bd"}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""千千音乐链接解析(对应 sdk/bd/parse.ts)。
|
||||
|
||||
歌曲:`music.91q.com/song/<TSID>`(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)。
|
||||
专辑:`music.91q.com/album/<P码>` / `?albumAssetCode=` / `?albumid=`。
|
||||
歌单:`music.91q.com/songlist|tracklist|playlist/<id>` / `?*id=` / 裸歌单 id。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.bd import album as album_module
|
||||
from app.sdk.platforms.bd import playlist as playlist_module
|
||||
from app.sdk.platforms.bd.music_info import get_song_info
|
||||
from app.sdk.platforms.bd.util import first_non_empty
|
||||
|
||||
_TSID_RE = re.compile(r"music\.91q\.com/song/(\w+)", re.ASCII)
|
||||
|
||||
|
||||
async def parse_song(link: str) -> dict[str, Any]:
|
||||
"""上游 Parse:歌曲链接 → Song(尽力附带直链 url/type)。"""
|
||||
match = _TSID_RE.search(str(link if link is not None else ""))
|
||||
if not match:
|
||||
raise RuntimeError("无效的千千歌曲链接")
|
||||
tsid = match.group(1)
|
||||
|
||||
song: dict[str, Any] = dict(await get_song_info(tsid))
|
||||
try:
|
||||
# 延迟导入:`apis/bd.py` 依赖本包的 util,顶层导入会让包初始化与 apis 互等
|
||||
from app.sdk.apis.bd import get_download_url
|
||||
|
||||
hit = await get_download_url(tsid)
|
||||
song["url"] = hit["url"]
|
||||
song["type"] = hit["type"]
|
||||
except Exception: # noqa: BLE001 - 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
pass
|
||||
song.pop("lyricUrl", None)
|
||||
song.pop("raw", None)
|
||||
return {**song, "songmid": first_non_empty(song.get("songmid"), tsid), "source": "bd"}
|
||||
|
||||
|
||||
async def parse_album(link: str) -> dict[str, Any]:
|
||||
return await album_module.parse_album_link(link)
|
||||
|
||||
|
||||
async def parse_playlist(link: str) -> dict[str, Any]:
|
||||
return await playlist_module.parse_playlist_link(link)
|
||||
@@ -0,0 +1,201 @@
|
||||
"""千千音乐歌单(对应 sdk/bd/playlist.ts)。
|
||||
|
||||
详情:`/v1/tracklist/info?id=&type=0&pageNo=&pageSize=`(支持分页,默认 pageSize=20)。
|
||||
分类:`/v1/tracklist/category`(group.categoryName + subCate[])。
|
||||
分类歌单:`/v1/tracklist/list?subCateId=&pageNo=&pageSize=`(不传 subCateId 为全部)。
|
||||
搜索:上游用 `/v1/search?type=6`,但 2026-10 实测服务端已下线该搜索类型
|
||||
(errno 23001 "不被支持的搜索类型(10000)"),官网搜索页也只有 单曲/歌手/专辑 三个 tab。
|
||||
上游在 !state 时静默返回空列表,本实现保持一致并保留 typeSonglist 解析以备恢复。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.bd.util import (
|
||||
APP_ID,
|
||||
api_get,
|
||||
api_ok,
|
||||
error_text,
|
||||
first_non_empty,
|
||||
map_track,
|
||||
playlist_link,
|
||||
to_int,
|
||||
)
|
||||
|
||||
|
||||
def _map_playlist_summary(item: Any) -> dict[str, Any]:
|
||||
source = item if isinstance(item, dict) else {}
|
||||
id = "" if source.get("id") is None else str(source.get("id"))
|
||||
raw_desc = str(source.get("desc") or "").strip()
|
||||
tag_list = source.get("tagList")
|
||||
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
|
||||
return {
|
||||
"id": id,
|
||||
"name": str(source.get("title") or "").strip(),
|
||||
"cover": str(source.get("pic") or "").strip(),
|
||||
"trackCount": to_int(source.get("trackCount"), 0),
|
||||
"creator": first_non_empty(
|
||||
source.get("creator"),
|
||||
source.get("author"),
|
||||
source.get("userName"),
|
||||
source.get("nickName"),
|
||||
source.get("ownerName"),
|
||||
),
|
||||
"description": raw_desc or "、".join(tags),
|
||||
"link": playlist_link(id) if id else "",
|
||||
"source": "bd",
|
||||
}
|
||||
|
||||
|
||||
async def search_playlist(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
"""上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表)。"""
|
||||
word = str(keyword if keyword is not None else "").strip()
|
||||
empty: dict[str, Any] = {"source": "bd", "list": [], "total": 0, "page": page, "limit": limit}
|
||||
if not word:
|
||||
return empty
|
||||
|
||||
body = await api_get(
|
||||
"/v1/search",
|
||||
{"word": word, "type": "6", "pageNo": str(page), "pageSize": str(limit), "appid": APP_ID},
|
||||
)
|
||||
# 上游此处只看 body.state(不看 errno),与服务端下线后的行为一致
|
||||
if not isinstance(body, dict) or not body.get("state"):
|
||||
return empty
|
||||
|
||||
data = body.get("data") or {}
|
||||
items = data.get("typeSonglist") if isinstance(data, dict) else None
|
||||
summaries = [_map_playlist_summary(item) for item in items] if isinstance(items, list) else []
|
||||
list_out = [item for item in summaries if item["id"]]
|
||||
total = to_int(data.get("total") if isinstance(data, dict) else None, len(list_out))
|
||||
return {"source": "bd", "list": list_out, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
async def get_playlist_detail(id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
|
||||
"""歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize)。"""
|
||||
playlist_id = str(id if id is not None else "").strip()
|
||||
if not playlist_id:
|
||||
raise RuntimeError("千千歌单 id 为空")
|
||||
|
||||
body = await api_get(
|
||||
"/v1/tracklist/info",
|
||||
{
|
||||
"id": playlist_id,
|
||||
"type": "0",
|
||||
"pageNo": str(max(1, page)),
|
||||
"pageSize": str(max(1, limit)),
|
||||
"appid": APP_ID,
|
||||
},
|
||||
)
|
||||
if not api_ok(body):
|
||||
raise RuntimeError(error_text(body, "千千歌单获取失败"))
|
||||
|
||||
data = (body.get("data") if isinstance(body, dict) else None) or {}
|
||||
data = data if isinstance(data, dict) else {}
|
||||
raw_tracks = data.get("trackList")
|
||||
tracks = [item for item in raw_tracks if isinstance(item, dict)] if isinstance(raw_tracks, list) else []
|
||||
songs = [song for song in (map_track(item) for item in tracks) if song["songmid"]]
|
||||
|
||||
title = first_non_empty(data.get("title"), playlist_id)
|
||||
tag_list = data.get("tagList")
|
||||
tags = [str(tag) for tag in tag_list] if isinstance(tag_list, list) else []
|
||||
cover = first_non_empty(data.get("pic"), songs[0]["img"] if songs else None)
|
||||
creator = first_non_empty(
|
||||
data.get("creator"),
|
||||
data.get("author"),
|
||||
data.get("userName"),
|
||||
data.get("nickName"),
|
||||
data.get("ownerName"),
|
||||
)
|
||||
description = first_non_empty(data.get("desc"), data.get("description")) or "、".join(tags)
|
||||
total = to_int(data.get("trackCount"), len(songs))
|
||||
|
||||
return {
|
||||
"source": "bd",
|
||||
"info": {
|
||||
"id": playlist_id,
|
||||
"name": title,
|
||||
"img": cover,
|
||||
"desc": description,
|
||||
"author": creator,
|
||||
"link": playlist_link(playlist_id),
|
||||
},
|
||||
"list": songs,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"allPage": math.ceil(total / max(1, limit)) or 1,
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_songs(id: str) -> list[dict[str, Any]]:
|
||||
"""上游 GetPlaylistSongs:只取曲目列表。"""
|
||||
detail = await get_playlist_detail(id, 1, 1000)
|
||||
return detail["list"]
|
||||
|
||||
|
||||
async def get_categories() -> dict[str, Any]:
|
||||
"""上游 GetPlaylistCategories:`{source, list: [{id, name, group, count}]}`,首项为「全部」(id='')。"""
|
||||
body = await api_get("/v1/tracklist/category", {"appid": APP_ID})
|
||||
if not api_ok(body):
|
||||
raise RuntimeError(error_text(body, "千千歌单分类获取失败"))
|
||||
|
||||
list_out: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
|
||||
groups = body.get("data") if isinstance(body, dict) else None
|
||||
for group in groups if isinstance(groups, list) else []:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
group_name = str(group.get("categoryName") or "").strip()
|
||||
sub_cate = group.get("subCate")
|
||||
for item in sub_cate if isinstance(sub_cate, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
id = str(item.get("id") or "").strip()
|
||||
name = str(item.get("categoryName") or "").strip()
|
||||
if not id or not name:
|
||||
continue
|
||||
list_out.append({"id": id, "name": name, "group": group_name, "count": to_int(item.get("count"), 0)})
|
||||
return {"source": "bd", "list": list_out}
|
||||
|
||||
|
||||
async def get_category_playlists(category_id: Any, page: int = 1, limit: int = 30) -> dict[str, Any]:
|
||||
"""上游 GetCategoryPlaylists(page >= 1,limit 1..100)。"""
|
||||
cate_id = str(category_id if category_id is not None else "").strip()
|
||||
page_no = max(1, to_int(page, 1))
|
||||
page_size = min(100, max(1, to_int(limit, 30)))
|
||||
|
||||
params: dict[str, Any] = {"appid": APP_ID, "pageNo": str(page_no), "pageSize": str(page_size)}
|
||||
if cate_id:
|
||||
params["subCateId"] = cate_id
|
||||
|
||||
body = await api_get("/v1/tracklist/list", params)
|
||||
if not api_ok(body):
|
||||
raise RuntimeError(error_text(body, "千千分类歌单获取失败"))
|
||||
|
||||
data = (body.get("data") if isinstance(body, dict) else None) or {}
|
||||
data = data if isinstance(data, dict) else {}
|
||||
result = data.get("result")
|
||||
summaries = (
|
||||
[{**_map_playlist_summary(item), "categoryId": cate_id} for item in result] if isinstance(result, list) else []
|
||||
)
|
||||
list_out = [item for item in summaries if item["id"]]
|
||||
total = to_int(data.get("total"), len(list_out))
|
||||
return {"source": "bd", "list": list_out, "total": total, "page": page_no, "limit": page_size}
|
||||
|
||||
|
||||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||||
"""上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id。"""
|
||||
raw = str(link if link is not None else "").strip()
|
||||
patterns = (
|
||||
re.compile(r"music\.91q\.com/(?:songlist|tracklist|playlist)/([A-Za-z0-9]+)"),
|
||||
re.compile(r"(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)"),
|
||||
)
|
||||
for pattern in patterns:
|
||||
match = pattern.search(raw)
|
||||
if match:
|
||||
return await get_playlist_detail(match.group(1))
|
||||
if raw and "/" not in raw:
|
||||
return await get_playlist_detail(raw)
|
||||
raise RuntimeError("无效的千千歌单链接")
|
||||
@@ -0,0 +1,296 @@
|
||||
"""千千音乐(music.91q.com / 91q.com)公共请求层(对应 sdk/bd/util.ts)。
|
||||
|
||||
上游全部接口都走 `/v1/*`,参数 = 业务参数 + appid + timestamp + sign:
|
||||
|
||||
sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
||||
|
||||
匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 只影响 VIP、收藏等登录态资源。
|
||||
|
||||
**签名串与 query 必须分开编码**:sign 用参数原值拼接(服务端拿收到的参数值重算),
|
||||
query 用 `URLSearchParams` 序列化。两者共用同一份 `sign_params` 结果,且 URL 拼好后
|
||||
直接交给 `http_fetch`(不再二次序列化),这是历史上「签名串 ≠ 实际 body」踩过的坑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.auth_store import get_cookie
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import size_formate
|
||||
|
||||
APP_ID = "16073360"
|
||||
SECRET = "0b50b02fd0d73a9c4c8c3a781c30845f"
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
)
|
||||
REFERER = "https://music.91q.com/player"
|
||||
API_BASE = "https://music.91q.com"
|
||||
|
||||
# 上游音质档位顺序(3000 = flac,其余为 kbps)
|
||||
RATE_ORDER = ["3000", "320", "128", "64"]
|
||||
|
||||
# URLSearchParams 不编码的字符集。与 Python quote 的默认安全字符不同:
|
||||
# URLSearchParams 保留 `*` 却编码 `~`,差一个字符服务端就验签失败,故自行实现。
|
||||
_FORM_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._")
|
||||
|
||||
# rateFileInfo 档位 → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
|
||||
_RATE_LABEL = {"128": "128k", "320": "320k", "3000": "flac"}
|
||||
|
||||
# 上游 sanitizeQianqianAlbumKeyword 的特殊字符集
|
||||
_ALBUM_KEYWORD_SPECIALS = re.compile(r"""[::"'“”‘’()()\[\]【】,,/\\\-.]""")
|
||||
|
||||
|
||||
def to_number(value: Any) -> float:
|
||||
"""JS `Number()` 语义:None(undefined) 与不可解析 → NaN,bool → 1/0,空串 → 0。"""
|
||||
if value is None:
|
||||
return math.nan
|
||||
if isinstance(value, bool):
|
||||
return 1.0 if value else 0.0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return math.nan
|
||||
|
||||
|
||||
def num_or_zero(value: Any) -> float:
|
||||
"""`Number(value ?? 0)` 语义:缺失(null/undefined)视作 0,不可解析才是 NaN。"""
|
||||
return 0.0 if value is None else to_number(value)
|
||||
|
||||
|
||||
def to_int(value: Any, fallback: int = 0) -> int:
|
||||
"""`Number(value) || fallback` 语义:0 / NaN / 不可解析都落到 fallback。"""
|
||||
number = to_number(value)
|
||||
if not number or math.isnan(number):
|
||||
return fallback
|
||||
return int(number)
|
||||
|
||||
|
||||
def form_encode(value: Any) -> str:
|
||||
"""`new URLSearchParams(...).toString()` 的逐字节等价实现(空格写 `+`,其余按 UTF-8 编码)。"""
|
||||
out: list[str] = []
|
||||
for byte in str(value).encode("utf-8"):
|
||||
char = chr(byte)
|
||||
if char in _FORM_SAFE:
|
||||
out.append(char)
|
||||
elif char == " ":
|
||||
out.append("+")
|
||||
else:
|
||||
out.append(f"%{byte:02X}")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def get_qian_cookie() -> str:
|
||||
"""千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致)。"""
|
||||
return get_cookie("bd")
|
||||
|
||||
|
||||
def request_headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Referer": REFERER,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
cookie = get_qian_cookie()
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
return headers
|
||||
|
||||
|
||||
def sign_params(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5。"""
|
||||
signed: dict[str, Any] = {str(key): value for key, value in params.items()}
|
||||
signed["timestamp"] = str(int(time.time()))
|
||||
payload = "&".join(f"{key}={signed[key]}" for key in sorted(signed)) + SECRET
|
||||
signed["sign"] = hashlib.md5(payload.encode("utf-8")).hexdigest()
|
||||
return signed
|
||||
|
||||
|
||||
def signed_url(path: str, params: dict[str, Any]) -> str:
|
||||
"""签名后的完整 URL:签名用原值,query 用 URLSearchParams 等价编码。"""
|
||||
query = "&".join(f"{key}={form_encode(value)}" for key, value in sign_params(params).items())
|
||||
return f"{API_BASE}{path}?{query}"
|
||||
|
||||
|
||||
async def api_get(path: str, params: dict[str, Any]) -> Any:
|
||||
"""发起带签名的 GET,返回响应 JSON body(非 200 按原文案抛错)。"""
|
||||
resp = await http_fetch(signed_url(path, params), method="get", headers=request_headers(), timeout=15000)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"千千音乐接口失败(HTTP {resp.status_code})")
|
||||
return resp.body if resp.body is not None else {}
|
||||
|
||||
|
||||
def api_ok(body: Any) -> bool:
|
||||
"""上游状态判定:state=true 或 errno=22000 视为成功。"""
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
return body.get("state") is True or to_number(body.get("errno")) == 22000
|
||||
|
||||
|
||||
def error_text(body: Any, prefix: str) -> str:
|
||||
"""上游错误文案:errmsg 优先,缺失时拼 errno(两种文案都逐字沿用原实现)。"""
|
||||
if not isinstance(body, dict):
|
||||
return prefix
|
||||
message = str(body.get("errmsg") or "").strip()
|
||||
if message:
|
||||
return message
|
||||
return f"{prefix}(errno {body.get('errno')})"
|
||||
|
||||
|
||||
async def track_link(tsid: str, rate: str) -> Any:
|
||||
"""tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path)。"""
|
||||
return await api_get("/v1/song/tracklink", {"TSID": tsid, "appid": APP_ID, "rate": rate})
|
||||
|
||||
|
||||
def interval_of(duration: Any) -> str | int:
|
||||
"""时间格式 mm:ss(duration 为秒;无效或 <=0 显示 0)。"""
|
||||
seconds = to_number(duration)
|
||||
if math.isnan(seconds) or seconds <= 0:
|
||||
return 0
|
||||
return f"{int(seconds // 60):02d}:{int(seconds % 60):02d}"
|
||||
|
||||
|
||||
def join_artists(artists: Any) -> str:
|
||||
"""千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接)。"""
|
||||
if not isinstance(artists, list) or not artists:
|
||||
return ""
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(name: Any) -> None:
|
||||
text = "" if name is None else str(name).strip()
|
||||
if not text or text in seen:
|
||||
return
|
||||
seen.add(text)
|
||||
names.append(text)
|
||||
|
||||
for item in artists:
|
||||
if isinstance(item, dict) and to_number(item.get("artistType")) == 38:
|
||||
add(item.get("name"))
|
||||
if not names:
|
||||
for item in artists:
|
||||
if isinstance(item, dict):
|
||||
add(item.get("name"))
|
||||
return "、".join(names)
|
||||
|
||||
|
||||
def normalize_album_asset_code(id: Any) -> str:
|
||||
"""上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P。"""
|
||||
text = ("" if id is None else str(id)).strip()
|
||||
if len(text) < 2:
|
||||
return ""
|
||||
return f"P{text[1:]}" if text[0] in ("p", "P") else ""
|
||||
|
||||
|
||||
def sanitize_album_keyword(keyword: Any) -> str:
|
||||
"""上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(23001 时重试用)。"""
|
||||
trimmed = ("" if keyword is None else str(keyword)).strip()
|
||||
if not trimmed:
|
||||
return ""
|
||||
return " ".join(part for part in _ALBUM_KEYWORD_SPECIALS.sub(" ", trimmed).split() if part)
|
||||
|
||||
|
||||
def first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
text = "" if value is None else str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def release_date10(raw: Any) -> str:
|
||||
"""上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD)。"""
|
||||
text = ("" if raw is None else str(raw)).strip()
|
||||
return text[:10] if len(text) >= 10 else text
|
||||
|
||||
|
||||
def rate_stats(rate_file_info: Any, duration: float) -> dict[str, int]:
|
||||
"""上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps)。"""
|
||||
for rate in RATE_ORDER:
|
||||
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
size = to_number(info.get("size"))
|
||||
if not size > 0:
|
||||
continue
|
||||
if duration > 0:
|
||||
return {"size": int(size), "bitrate": int(size * 8 / 1000 / duration)}
|
||||
return {"size": int(size), "bitrate": 800 if rate == "3000" else int(rate)}
|
||||
return {"size": 0, "bitrate": 0}
|
||||
|
||||
|
||||
def rate_types(rate_file_info: Any) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
"""rateFileInfo → 既有 Quality 桶的 types / _types。"""
|
||||
types: list[dict[str, Any]] = []
|
||||
type_map: dict[str, dict[str, Any]] = {}
|
||||
for rate in ("128", "320", "3000"):
|
||||
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
size = to_number(info.get("size"))
|
||||
if not size > 0:
|
||||
continue
|
||||
label = _RATE_LABEL[rate]
|
||||
size_text = size_formate(int(size))
|
||||
types.append({"type": label, "size": size_text})
|
||||
type_map[label] = {"size": size_text}
|
||||
return types, type_map
|
||||
|
||||
|
||||
def map_track(
|
||||
item: Any,
|
||||
*,
|
||||
album_id: str = "",
|
||||
album_name: str = "",
|
||||
img: str | None = None,
|
||||
singer: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""千千曲目字段 → MusicServer 统一 Song 形状。
|
||||
|
||||
搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
|
||||
歌单详情用 TSID + albumTitle + pic。
|
||||
注:上游 Search 会跳过 isVip != 0 的曲目;本实现保留它们并标记 payPlay,
|
||||
取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
|
||||
"""
|
||||
source = item if isinstance(item, dict) else {}
|
||||
duration = num_or_zero(source.get("duration"))
|
||||
stats = rate_stats(source.get("rateFileInfo"), duration)
|
||||
types, type_map = rate_types(source.get("rateFileInfo"))
|
||||
return {
|
||||
"songmid": first_non_empty(source.get("TSID"), source.get("assetId"), source.get("tsid")),
|
||||
"name": str(source.get("title") or "").strip(),
|
||||
"singer": first_non_empty(join_artists(source.get("artist")), singer),
|
||||
"albumId": first_non_empty(normalize_album_asset_code(source.get("albumAssetCode")), album_id),
|
||||
"albumName": first_non_empty(source.get("albumTitle"), album_name),
|
||||
"interval": interval_of(duration),
|
||||
"size": stats["size"] or 0,
|
||||
"bitrate": stats["bitrate"] or 0,
|
||||
"payPlay": num_or_zero(source.get("isVip")) != 0 or num_or_zero(source.get("isPaid")) != 0,
|
||||
"img": first_non_empty(source.get("pic"), img) or None,
|
||||
"lrc": None,
|
||||
"lrcx": "",
|
||||
"types": types,
|
||||
"_types": type_map,
|
||||
"source": "bd",
|
||||
}
|
||||
|
||||
|
||||
def song_link(tsid: str) -> str:
|
||||
return f"https://music.91q.com/song/{tsid}"
|
||||
|
||||
|
||||
def album_link(id: str) -> str:
|
||||
return f"https://music.91q.com/album/{id}"
|
||||
|
||||
|
||||
def playlist_link(id: str) -> str:
|
||||
return f"https://music.91q.com/songlist/{id}"
|
||||
Reference in new issue
Block a user