Files
MusicServer/app/sdk/platforms/bd/playlist.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

202 lines
8.1 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.
"""千千音乐歌单(对应 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("无效的千千歌单链接")