- 新增 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 路由验证通过
319 lines
13 KiB
Python
319 lines
13 KiB
Python
"""Bilibili 歌单(对应 sdk/bili/playlist.ts,上游 bilibili/playlist.go)。
|
||
|
||
B 站的「歌单」= 合集 ugc_season / 多分P稿件:
|
||
* search_playlist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
|
||
* get_playlist_detail / parse_playlist_link: id 约定 ``season:<season_id>:<mid>:<bvid>`` 或 ``bvid:<bvid>``;
|
||
* get_categories / get_category_playlists: 上游显式返回 ErrPlaylistCategoriesUnsupported,保持不支持(抛错)。
|
||
|
||
注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.sdk.platforms.bili.music_search import SEARCH_URL
|
||
from app.sdk.platforms.bili.util import (
|
||
api_get,
|
||
as_dict,
|
||
as_list,
|
||
build_songs_from_pages,
|
||
build_songs_from_season_sections,
|
||
clean_title,
|
||
encode_uri_component,
|
||
extract_aid,
|
||
extract_bvid,
|
||
fetch_page_list,
|
||
fetch_season_archive_index,
|
||
fetch_season_songs,
|
||
fetch_view,
|
||
map_limit,
|
||
normalize_cover,
|
||
to_number,
|
||
)
|
||
|
||
|
||
def _count_season_episodes(sections: list[Any]) -> int:
|
||
count = 0
|
||
for sec in sections or []:
|
||
if isinstance(sec, dict):
|
||
episodes = sec.get("episodes")
|
||
count += len(episodes) if isinstance(episodes, list) else 0
|
||
return count
|
||
|
||
|
||
def _season_key(season_id: Any, mid: Any, bvid: str) -> str:
|
||
return f"season:{season_id}:{mid}:{bvid}"
|
||
|
||
|
||
def _bvid_key(bvid: str) -> str:
|
||
return f"bvid:{bvid}"
|
||
|
||
|
||
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||
"""搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力)。"""
|
||
p = max(1, int(to_number(page)) or 1)
|
||
size = min(max(1, int(to_number(limit)) or 20), 50)
|
||
url = f"{SEARCH_URL}?search_type=video&keyword={encode_uri_component(keyword)}&page={p}&page_size={size}"
|
||
resp = await api_get(url)
|
||
body = resp.body
|
||
if resp.status_code != 200 or not isinstance(body, dict):
|
||
raise RuntimeError(f"B站歌单搜索失败(HTTP {resp.status_code})")
|
||
if body.get("code") != 0:
|
||
message = body.get("message")
|
||
raise RuntimeError(f"B站歌单搜索失败({body.get('code')}{f': {message}' if message else ''})")
|
||
|
||
data = as_dict(body.get("data"))
|
||
items = as_list(data.get("result"))
|
||
seen: set[str] = set()
|
||
|
||
async def one(item: Any, _index: int) -> dict[str, Any] | None:
|
||
if not isinstance(item, dict) or not item.get("bvid"):
|
||
return None
|
||
view = await fetch_view(item["bvid"])
|
||
cover = normalize_cover(item.get("pic") or view.get("pic") or "")
|
||
owner = as_dict(view.get("owner"))
|
||
season = as_dict(view.get("ugc_season")) or None
|
||
if season:
|
||
playlist_id = _season_key(season.get("id"), owner.get("mid"), str(item["bvid"]))
|
||
if playlist_id in seen:
|
||
return None
|
||
seen.add(playlist_id)
|
||
sections = as_list(season.get("sections"))
|
||
track_count = _count_season_episodes(sections)
|
||
if not track_count and season.get("id") and owner.get("mid"):
|
||
try:
|
||
track_count = len(await fetch_season_songs(owner.get("mid"), season.get("id")))
|
||
except Exception: # noqa: BLE001 - 上游 fetchSeasonSongs 失败同样保持 trackCount 0
|
||
pass
|
||
stat = as_dict(season.get("stat"))
|
||
return {
|
||
"id": playlist_id,
|
||
"name": str(season.get("title") or clean_title(item.get("title"))),
|
||
"cover": normalize_cover(season.get("cover")) or cover,
|
||
"trackCount": track_count,
|
||
"playCount": int(to_number(stat.get("view"))) or None,
|
||
"creator": str(owner.get("name") or item.get("author") or ""),
|
||
"description": str(season.get("intro") or ""),
|
||
"link": f"https://www.bilibili.com/video/{item['bvid']}",
|
||
"source": "bili",
|
||
}
|
||
pages = as_list(view.get("pages"))
|
||
if len(pages) > 1:
|
||
playlist_id = _bvid_key(str(item["bvid"]))
|
||
if playlist_id in seen:
|
||
return None
|
||
seen.add(playlist_id)
|
||
return {
|
||
"id": playlist_id,
|
||
"name": clean_title(item.get("title")),
|
||
"cover": cover,
|
||
"trackCount": len(pages),
|
||
"creator": str(owner.get("name") or item.get("author") or ""),
|
||
"description": "",
|
||
"link": f"https://www.bilibili.com/video/{item['bvid']}",
|
||
"source": "bili",
|
||
}
|
||
return None
|
||
|
||
result = await map_limit(items, 4, one)
|
||
result_list = [item for item in result if item]
|
||
return {"list": result_list, "page": p, "limit": size, "total": len(result_list), "source": "bili"}
|
||
|
||
|
||
async def _detail_from_bvid(bvid: str) -> dict[str, Any]:
|
||
"""由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据)。"""
|
||
view = await fetch_view(bvid)
|
||
real_bvid = str(view.get("bvid") or bvid)
|
||
pages = as_list(view.get("pages"))
|
||
if len(pages) <= 1:
|
||
try:
|
||
page_list = await fetch_page_list(real_bvid)
|
||
except Exception: # noqa: BLE001 - 分P列表接口失败按上游语义忽略
|
||
page_list = []
|
||
if page_list:
|
||
pages = page_list
|
||
if not pages:
|
||
raise RuntimeError("未找到视频分P")
|
||
owner = as_dict(view.get("owner"))
|
||
songs = build_songs_from_pages(
|
||
real_bvid, str(view.get("title") or ""), str(owner.get("name") or ""), str(view.get("pic") or ""), pages
|
||
)
|
||
return {
|
||
"info": {
|
||
"name": str(view.get("title") or real_bvid),
|
||
"img": normalize_cover(view.get("pic") or ""),
|
||
"desc": str(view.get("desc") or ""),
|
||
"author": str(owner.get("name") or ""),
|
||
},
|
||
"songs": songs,
|
||
}
|
||
|
||
|
||
async def _detail_from_season(season_id: str, mid: str, bvid: str) -> dict[str, Any]:
|
||
"""由 ``season:<season_id>:<mid>:<bvid>`` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支)。"""
|
||
info: dict[str, Any] = {"name": "", "img": "", "desc": "", "author": ""}
|
||
songs: list[dict[str, Any]] = []
|
||
|
||
if bvid:
|
||
view = await fetch_view(bvid)
|
||
season = as_dict(view.get("ugc_season")) or None
|
||
if season:
|
||
archive_index: dict[str, dict[str, Any]] = {}
|
||
season_title = str(season.get("title") or "")
|
||
season_cover = str(season.get("cover") or "")
|
||
# 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
|
||
try:
|
||
idx = await fetch_season_archive_index(mid, season_id)
|
||
except Exception: # noqa: BLE001
|
||
idx = None
|
||
if idx:
|
||
archive_index = idx["index"]
|
||
season_title = season_title or idx["title"]
|
||
season_cover = season_cover or idx["cover"]
|
||
owner = as_dict(view.get("owner"))
|
||
songs = build_songs_from_season_sections(
|
||
as_list(season.get("sections")),
|
||
season_title,
|
||
season_cover,
|
||
str(owner.get("name") or ""),
|
||
archive_index,
|
||
)
|
||
info = {
|
||
"name": season_title or str(view.get("title") or ""),
|
||
"img": normalize_cover(season_cover or view.get("pic") or ""),
|
||
"desc": str(season.get("intro") or ""),
|
||
"author": str(owner.get("name") or ""),
|
||
}
|
||
|
||
if not songs:
|
||
songs = await fetch_season_songs(mid, season_id)
|
||
if not info["name"]:
|
||
try:
|
||
idx = await fetch_season_archive_index(mid, season_id)
|
||
except Exception: # noqa: BLE001
|
||
idx = None
|
||
if idx:
|
||
info = {**info, "name": idx["title"] or "", "img": normalize_cover(idx["cover"] or "")}
|
||
if not songs:
|
||
raise RuntimeError("合集为空或合集接口不可用")
|
||
return {"info": info, "songs": songs}
|
||
|
||
|
||
async def _resolve_id(playlist_id: str) -> dict[str, Any]:
|
||
"""解析 id(兼容链接/裸 BV/av/bvid: 前缀)。"""
|
||
raw = str(playlist_id or "").strip()
|
||
if re.match(r"^https?:", raw, re.IGNORECASE):
|
||
return {"kind": "link", "value": raw}
|
||
if re.match(r"^season:", raw, re.IGNORECASE):
|
||
return {"kind": "season", "value": raw, "parts": raw.split(":")}
|
||
# 注意先去 `bvid:` 前缀再提 BV:大小写不敏感的 BV 匹配会把前缀 "bvid" 本身当成 BV 号
|
||
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
|
||
if bvid:
|
||
return {"kind": "bvid", "value": bvid}
|
||
aid = extract_aid(raw)
|
||
if aid:
|
||
view = await fetch_view(aid)
|
||
real_bvid = str(view.get("bvid") or "")
|
||
if not real_bvid:
|
||
raise RuntimeError("未找到视频 BV 号")
|
||
return {"kind": "bvid", "value": real_bvid}
|
||
raise RuntimeError("无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)")
|
||
|
||
|
||
def _page_slice(detail: dict[str, Any], page: Any, limit: Any) -> dict[str, Any]:
|
||
p = max(1, int(to_number(page)) or 1)
|
||
size = min(max(1, int(to_number(limit)) or 100), 10000)
|
||
songs = detail["songs"]
|
||
return {
|
||
"source": "bili",
|
||
"info": detail["info"],
|
||
"list": songs[(p - 1) * size : p * size],
|
||
"page": p,
|
||
"limit": size,
|
||
"total": len(songs),
|
||
}
|
||
|
||
|
||
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||
"""歌单详情(契约键 playlist.get_playlist_detail)。"""
|
||
target = await _resolve_id(playlist_id)
|
||
if target["kind"] == "link":
|
||
return await parse_playlist_link(target["value"])
|
||
if target["kind"] == "season":
|
||
parts = target.get("parts") or []
|
||
if len(parts) < 3:
|
||
raise RuntimeError("无效的合集 id")
|
||
bvid = parts[3] if len(parts) > 3 else ""
|
||
return _page_slice(await _detail_from_season(parts[1], parts[2], bvid), page, limit)
|
||
return _page_slice(await _detail_from_bvid(target["value"]), page, limit)
|
||
|
||
|
||
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
|
||
"""上游 GetPlaylistSongs 的等价导出(只返回歌曲数组)。"""
|
||
detail = await get_playlist_detail(playlist_id, 1, 10000)
|
||
return detail["list"]
|
||
|
||
|
||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||
"""解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量)。"""
|
||
raw = str(link or "").strip()
|
||
bvid = extract_bvid(re.sub(r"^bvid:", "", raw, flags=re.IGNORECASE))
|
||
if not bvid:
|
||
aid = extract_aid(raw)
|
||
if not aid:
|
||
raise RuntimeError("无效的B站歌单链接:未找到 BV/av 号")
|
||
view = await fetch_view(aid)
|
||
return await _parse_playlist_view(str(view.get("bvid") or ""), view)
|
||
view = await fetch_view(bvid)
|
||
return await _parse_playlist_view(bvid, view)
|
||
|
||
|
||
async def _parse_playlist_view(bvid: str, view: dict[str, Any]) -> dict[str, Any]:
|
||
owner = as_dict(view.get("owner"))
|
||
season = as_dict(view.get("ugc_season")) or None
|
||
if season:
|
||
playlist_id = _season_key(season.get("id"), owner.get("mid"), bvid)
|
||
detail = await _detail_from_season(
|
||
str(season.get("id")), str(owner.get("mid") if owner.get("mid") is not None else ""), bvid
|
||
)
|
||
sections = as_list(season.get("sections"))
|
||
track_count = _count_season_episodes(sections)
|
||
if not track_count:
|
||
track_count = len(detail["songs"])
|
||
stat = as_dict(season.get("stat"))
|
||
return {
|
||
"source": "bili",
|
||
"id": playlist_id,
|
||
"info": {
|
||
**detail["info"],
|
||
"trackCount": track_count,
|
||
"playCount": int(to_number(stat.get("view"))) or None,
|
||
},
|
||
"list": detail["songs"],
|
||
"page": 1,
|
||
"limit": max(len(detail["songs"]), 1),
|
||
"total": len(detail["songs"]),
|
||
}
|
||
detail = await _detail_from_bvid(bvid)
|
||
return {
|
||
"source": "bili",
|
||
"id": _bvid_key(bvid),
|
||
"info": {**detail["info"], "trackCount": len(detail["songs"])},
|
||
"list": detail["songs"],
|
||
"page": 1,
|
||
"limit": max(len(detail["songs"]), 1),
|
||
"total": len(detail["songs"]),
|
||
}
|
||
|
||
|
||
async def get_categories() -> dict[str, Any]:
|
||
"""上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported。"""
|
||
raise RuntimeError("B站不支持歌单分类(上游未实现)")
|
||
|
||
|
||
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 20) -> dict[str, Any]:
|
||
"""上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported。"""
|
||
raise RuntimeError("B站不支持歌单分类(上游未实现)")
|