- 新增 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 路由验证通过
202 lines
8.1 KiB
Python
202 lines
8.1 KiB
Python
"""千千音乐歌单(对应 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("无效的千千歌单链接")
|