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:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
"""平台包集合。每个子包导出 `MODULE: Platform`。
目录名与平台 id 的差异:5sing → fivesing(Python 标识符不能以数字开头)。
"""
+110
View File
@@ -0,0 +1,110 @@
"""平台模块契约 —— 各平台包导出 `MODULE = Platform(...)`。
对应原 TS 的 `sdk/<platform>/index.ts` default export 对象:
const kw = { musicSearch, leaderboard, songList, hotSearch, comment, album, playlist, parse,
getMusicUrl, getLyric, getPic, handleMusicInfo, getMusicInfo, ... }
Python 侧用 `Platform` 容器承载子模块(属性名 snake_case),能力位缺省为 None 表示不支持,
路由层据此返回 404(与 /sources 的能力位同源)。
子模块协议:
music_search.search(keyword, page=1, limit=20, retry_num=0) -> {"list": [...], ...}
leaderboard.get_boards() -> {"list": [{"id", "name", "bangid"}]}
leaderboard.get_list(bangid, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.get_list(sort_id, tag_id, page=1) -> {"list", "total", "page", "limit", "source"}
song_list.sort_list -> [{"id", "name"}](可选,路由取首项作默认排序)
hot_search.get_list() -> {"list": [str], "source"}
singer.get_info(id) / get_song_list(id, page, limit) / get_album_list(id, page, limit)
album.get_album_detail(id, page, limit) | get_album_list_detail(id, page)
album.search_album(keyword, page, limit)
playlist.get_playlist_detail(id, page, limit) | get_playlist_songs(id)
playlist.search_playlist(keyword, page, limit) | get_categories() | get_category_playlists(id, page, limit)
parse.parse_song(link) / parse_album(link) / parse_playlist(link)
user_playlist.get_list(page, limit)
顶层方法(由 Platform 直接持有):
get_music_url(song_info, quality, retry_num=0) -> {"type", "url"}
get_lyric(song_info) -> {"lyric", "tlyric", "rlyric"?, "yrc"?}
get_pic(song_info) -> str
get_music_info(song_info) -> dict(可选,酷我兜底等场景需要)
handle_music_info(song_info) -> dict(可选,补全歌名/歌手/封面)
get_music_detail_page_url(song_info) -> str(可选)
"""
from __future__ import annotations
from typing import Any
# 能力位名称(/sources 的 capabilities 键 → Platform 属性)
CAPABILITY_ATTRS = {
"search": "music_search",
"url": "get_music_url",
"lyric": "get_lyric",
"pic": "get_pic",
"boards": "leaderboard",
"hotSearch": "hot_search",
"songList": "song_list",
"singer": "singer",
"playlist": "playlist",
"parse": "parse",
"userPlaylist": "user_playlist",
"comment": "comment",
}
class Platform:
"""平台模块容器。只声明属性,不做校验 —— 缺省即不支持。"""
# 平台短码与中文名(/sources 用)
id: str = ""
name: str = ""
music_search: Any = None
leaderboard: Any = None
song_list: Any = None
hot_search: Any = None
comment: Any = None
singer: Any = None
album: Any = None
playlist: Any = None
parse: Any = None
user_playlist: Any = None
get_music_url: Any = None
get_lyric: Any = None
get_pic: Any = None
get_music_info: Any = None
handle_music_info: Any = None
get_music_urls: Any = None
get_music_detail_page_url: Any = None
init: Any = None
def __init__(self, **kwargs: Any) -> None:
for key, value in kwargs.items():
setattr(self, key, value)
def __repr__(self) -> str:
caps = [key for key in CAPABILITY_ATTRS.values() if getattr(self, key, None) is not None]
return f"<Platform {self.id} caps={','.join(caps)}>"
def capability_map(platform: Platform) -> dict[str, bool]:
"""生成 /sources 的 capabilities 字段(键名沿用原 API 的驼峰写法)。"""
caps: dict[str, bool] = {name: getattr(platform, attr, None) is not None for name, attr in CAPABILITY_ATTRS.items()}
album = platform.album
caps["album"] = album is not None and (
getattr(album, "get_album_detail", None) is not None
or getattr(album, "get_album_list_detail", None) is not None
)
caps["albumSearch"] = album is not None and getattr(album, "search_album", None) is not None
playlist = platform.playlist
caps["playlist"] = playlist is not None and getattr(playlist, "get_playlist_detail", None) is not None
caps["playlistSearch"] = playlist is not None and getattr(playlist, "search_playlist", None) is not None
caps["playlistCategories"] = playlist is not None and getattr(playlist, "get_categories", None) is not None
parse = platform.parse
caps["parse"] = parse is not None and any(
getattr(parse, f"parse_{kind}", None) is not None for kind in ("song", "album", "playlist")
)
caps.pop("comment", None)
return caps
+134
View File
@@ -0,0 +1,134 @@
"""Apple Music 平台模块(对应 sdk/apple/index.ts,上游 music-lib/apple/apple.go 的 Node/TS 移植)。
能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到)/ 封面 /
专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 apis/apple.py)。
凭据:auth_store 的 apple 记录,cookie 形如
``"media-user-token=xxx;storefront=cn;token=xxx"``,也支持 storefront/mediaUserToken/token 直填字段。
未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.apple import album, lyric, music_search, parse, playlist
from app.sdk.platforms.apple.util import (
apple_artwork_url,
apple_song_from_catalog,
apple_web_url,
extract_apple_id,
fetch_apple_song_resource,
get_apple_config,
get_apple_token,
)
def _song_id_of(song_info: dict[str, Any]) -> str:
return extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
async def _init() -> bool:
"""预热 developer token(失败不阻塞启动,路由调用时会重试)。"""
try:
await get_apple_token()
return True
except Exception: # noqa: BLE001 - token 预热失败不影响后续请求的重试
return False
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.apple import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort)。"""
if song_info.get("img"):
return str(song_info["img"])
song_id = _song_id_of(song_info)
if not song_id:
return None
try:
res = await fetch_apple_song_resource(song_id)
if not res:
return None
return apple_artwork_url(res.get("attributes", {}).get("artwork"), 600) or None
except Exception: # noqa: BLE001 - 封面取不到返回 None
return None
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""歌曲详情(best-effort;等价上游 Parse(id))。"""
song_id = _song_id_of(song_info)
if not song_id:
return None
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
return apple_song_from_catalog(res) if res else None
class _AppleAlbum:
"""契约 album 能力位(含 TS index.ts 里挂的 parseAlbumLink)。"""
get_album_detail = staticmethod(album.get_album_detail)
get_album_songs = staticmethod(album.get_album_songs)
search_album = staticmethod(album.search_album)
parse_album_link = staticmethod(album.parse_album_link)
class _AppleSongList:
"""契约别名 songList:Apple 的歌单分类即 genre curators,供 /song-list/apple 复用。"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "热门", "id": "1526756058"}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, limit: int = 20) -> dict[str, Any]:
cate = str(tag_id or sort_id or "").strip()
return await playlist.get_category_playlists(cate, page, limit)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
song_id = _song_id_of(song_info)
if not song_id:
return "https://music.apple.com/"
return apple_web_url("song", song_id, get_apple_config()["storefront"])
def status() -> dict[str, Any]:
"""登录状态(media-user-token 存在性判断;无在线校验接口)。"""
cred = get_credentials("apple") or {}
cfg = get_apple_config()
if cfg["mediaUserToken"] or cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="apple",
name="Apple Music",
init=_init,
music_search=music_search,
album=_AppleAlbum,
playlist=playlist,
song_list=_AppleSongList,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+89
View File
@@ -0,0 +1,89 @@
"""Apple Music 专辑(对应 sdk/apple/album.ts,上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)。
详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
→ data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
链接:extract_apple_id(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_album
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
async def get_album_detail(raw_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(page/limit 在完整曲目上切片,与其它平台 get_album_detail 契约一致)。"""
album_id = extract_apple_id(str(raw_id or ""), "album")
if not album_id:
raise RuntimeError(f"invalid apple music album id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/albums/{album_id}", {"extend": "extendedAssetUrls"})
)
data = as_list(body.get("data"))
album = as_dict(data[0]) if data else {}
if not album:
raise RuntimeError(f"apple album not found: {album_id}")
tracks = await fetch_all_track_resources(as_dict(album.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track, album_id) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 50))
summary = apple_album_to_playlist(album)
# 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": album_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
"releaseDate": summary["extra"]["release_date"],
"recordLabel": summary["extra"]["record_label"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""上游 GetAlbumSongs:只返回曲目数组。"""
detail = await get_album_detail(album_id, 1, 100000)
return detail["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页)。"""
detail = await get_album_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = ["get_album_detail", "get_album_songs", "parse_album_link", "search_album"]
+45
View File
@@ -0,0 +1,45 @@
"""Apple Music 歌词(对应 sdk/apple/lyric.ts,上游 apple.go GetLyrics)。
通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
→ data[0].relationships.lyrics.data[*].attributes.ttml
上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
(即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词需要 media-user-token(订阅态);
本实现保持上游行为:拿不到就返回 ``{lyric: '', tlyric: ''}``,不抛错。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import apple_amp_get_tracked, extract_apple_id, get_apple_config
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
song_id = extract_apple_id(str(song_info.get("songmid") or song_info.get("id") or ""), "song")
if not song_id:
raise RuntimeError("apple 歌曲 id 无效")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get_tracked(
f"/v1/catalog/{cfg['storefront']}/songs/{song_id}",
{"include": "lyrics", "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
first = as_dict(data[0]) if data else {}
relationships = as_dict(first.get("relationships"))
lyrics = as_list(as_dict(relationships.get("lyrics")).get("data"))
for rel in lyrics:
text = str(as_dict(as_dict(rel).get("attributes")).get("ttml") or "")
if text:
return {"lyric": text, "tlyric": ""}
return {"lyric": "", "tlyric": ""}
async def get_lyrics_text(song_id: str) -> str:
"""非取消句柄版:直接返回歌词文本(内部/测试用)。"""
result = await get_lyric({"songmid": song_id})
return result.get("lyric") or ""
+90
View File
@@ -0,0 +1,90 @@
"""Apple Music 搜索(对应 sdk/apple/musicSearch.ts,上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)。
通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.py)
单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.util import (
apple_album_to_playlist,
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
get_apple_config,
)
#: amp-api search 单页硬上限
SEARCH_PAGE_MAX = 25
async def _catalog_search(keyword: str, types: str, page: int, limit: int) -> dict[str, Any]:
word = str(keyword or "").strip()
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 100)
if not word:
return {"body": None, "page": p, "limit": size}
cfg = get_apple_config()
body = await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/search",
{
"term": word,
"types": types,
"limit": min(size, SEARCH_PAGE_MAX),
"offset": (p - 1) * size,
},
)
return {"body": body, "page": p, "limit": size}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,amp-api 无重试语义
result = await _catalog_search(keyword, "songs", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
bucket = as_dict(results.get("songs"))
songs = [apple_song_from_catalog(item) for item in as_list(bucket.get("data"))]
has_next = bool(bucket.get("next"))
return {
"list": songs,
"allPage": p + 1 if has_next else p,
"total": (p - 1) * size + len(songs),
"limit": size,
"source": "apple",
}
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchAlbum:搜索专辑(返回歌单摘要形状)。"""
result = await _catalog_search(keyword, "albums", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("albums")).get("data"))
songs = [apple_album_to_playlist(item) for item in items]
return {
"list": songs,
"total": (p - 1) * size + len(songs),
"page": p,
"limit": size,
"source": "apple",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist:搜索歌单。"""
result = await _catalog_search(keyword, "playlists", page, limit)
p, size = result["page"], result["limit"]
results = as_dict(as_dict(result["body"]).get("results"))
items = as_list(as_dict(results.get("playlists")).get("data"))
playlists = [apple_playlist_to_playlist(item) for item in items]
return {
"list": playlists,
"total": (p - 1) * size + len(playlists),
"page": p,
"limit": size,
"source": "apple",
}
+34
View File
@@ -0,0 +1,34 @@
"""Apple Music 链接解析(对应 sdk/apple/parse.ts,上游 apple.go Parse / ParseAlbum / ParsePlaylist)。
song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.apple.album import parse_album_link
from app.sdk.platforms.apple.playlist import parse_playlist_link
from app.sdk.platforms.apple.util import apple_song_from_catalog, extract_apple_id, fetch_apple_song_resource
async def parse_song(link: str) -> dict[str, Any]:
"""上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链)。"""
song_id = extract_apple_id(str(link or ""), "song")
if not song_id:
raise RuntimeError(f"invalid apple music song link: {link}")
res = await fetch_apple_song_resource(song_id, "lyrics,albums")
if not res:
raise RuntimeError(f"apple song not found: {song_id}")
return apple_song_from_catalog(res)
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+177
View File
@@ -0,0 +1,177 @@
"""Apple Music 歌单(对应 sdk/apple/playlist.ts,上游 apple.go 的歌单相关接口)。
详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
曲目超过一页时沿 relationships.tracks.next 全量拉取(带 20 页上限防死循环)。
分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
链接:extract_apple_id(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.apple.music_search import search_playlist
from app.sdk.platforms.apple.util import (
apple_amp_get,
apple_playlist_to_playlist,
apple_song_from_catalog,
extract_apple_id,
fetch_all_track_resources,
get_apple_config,
)
#: 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators)
APPLE_CURATOR_CATEGORIES: list[dict[str, str]] = [
{"id": "1526756058", "name": "热门"},
{"id": "1479949880", "name": "C-Pop"},
{"id": "1019400042", "name": "国语流行"},
{"id": "1019398918", "name": "粤语流行"},
{"id": "1019399540", "name": "国际流行"},
{"id": "1019399551", "name": "K-Pop"},
{"id": "1019399547", "name": "J-Pop"},
{"id": "989061415", "name": "嘻哈/说唱"},
{"id": "1019400044", "name": "R&B"},
{"id": "1019400046", "name": "摇滚"},
{"id": "1019397973", "name": "另类音乐"},
{"id": "976439535", "name": "舞曲"},
{"id": "976439536", "name": "电子"},
{"id": "976439541", "name": "独立音乐"},
{"id": "1019399549", "name": "爵士乐"},
{"id": "1019398924", "name": "古典音乐"},
{"id": "976439528", "name": "蓝调"},
{"id": "976439534", "name": "乡村音乐"},
{"id": "1531542847", "name": "拉丁音乐"},
{"id": "988656348", "name": "非洲音乐"},
{"id": "976439543", "name": "金属乐"},
{"id": "976439550", "name": "朋克乐"},
{"id": "1019400049", "name": "不插电"},
{"id": "1231181168", "name": "影视原声"},
{"id": "1441811365", "name": "DJ 混音精选"},
{"id": "1532467784", "name": "瞩目之星"},
{"id": "1554938339", "name": "年代之声"},
{"id": "1564180390", "name": "空间音频"},
{"id": "989010186", "name": "亲子"},
]
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:静态返回 Apple Music genre curators。"""
return {
"source": "apple",
"list": [
{"id": item["id"], "name": item["name"], "group": "Apple Music 分类", "source": "apple"}
for item in APPLE_CURATOR_CATEGORIES
],
}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐)。"""
cate = str(category_id or "").strip()
if not cate:
raise RuntimeError("apple 分类 id 不能为空")
p = max(1, int(page or 1))
size = min(max(1, int(limit or 20)), 300)
api_max = 25
offset = (p - 1) * size
all_playlists: list[dict[str, Any]] = []
while len(all_playlists) < size:
batch_size = min(api_max, size - len(all_playlists))
try:
body = as_dict(
await apple_amp_get(
f"/v1/catalog/cn/apple-curators/{cate}/playlists",
{"limit": batch_size, "offset": offset, "l": "zh-Hans-CN"},
)
)
except Exception: # 上游:已有数据时返回已取部分
if all_playlists:
break
raise
data = as_list(body.get("data"))
all_playlists.extend(apple_playlist_to_playlist(item) for item in data)
# 没有更多数据或没有下一页
if len(data) < batch_size or not body.get("next"):
break
offset += len(data)
return {"source": "apple", "list": all_playlists[:size], "page": p, "limit": size, "total": len(all_playlists)}
async def get_playlist_detail(raw_id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""歌单详情(全量曲目后按 page/limit 切片)。"""
playlist_id = extract_apple_id(str(raw_id or ""), "playlist")
if not playlist_id:
raise RuntimeError(f"invalid apple music playlist id/link: {raw_id}")
cfg = get_apple_config()
body = as_dict(
await apple_amp_get(
f"/v1/catalog/{cfg['storefront']}/playlists/{playlist_id}",
{"limit[tracks]": 300, "extend": "extendedAssetUrls"},
)
)
data = as_list(body.get("data"))
item = as_dict(data[0]) if data else {}
if not item:
raise RuntimeError(f"apple playlist not found: {playlist_id}")
tracks = await fetch_all_track_resources(as_dict(item.get("relationships")).get("tracks"))
songs = [apple_song_from_catalog(track) for track in tracks]
p = max(1, int(page or 1))
size = max(1, int(limit or 100))
summary = apple_playlist_to_playlist(item)
if not summary["trackCount"] or len(songs) > summary["trackCount"]:
summary["trackCount"] = len(songs)
return {
"source": "apple",
"info": {
"id": playlist_id,
"name": summary["name"],
"img": summary["cover"],
"desc": summary["description"],
"author": summary["creator"],
"link": summary["link"],
"trackCount": summary["trackCount"],
},
"list": songs[(p - 1) * size : (p - 1) * size + size],
"page": p,
"limit": size,
"total": len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:只返回曲目数组。"""
detail = await get_playlist_detail(playlist_id, 1, 100000)
return detail["list"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页)。"""
detail = await get_playlist_detail(link, 1, 100000)
return {
"source": "apple",
"id": detail["info"]["id"],
"info": detail["info"],
"list": detail["list"],
"page": 1,
"limit": max(detail["total"], 1),
"total": detail["total"],
}
__all__ = [
"APPLE_CURATOR_CATEGORIES",
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"parse_playlist_link",
"search_playlist",
]
+465
View File
@@ -0,0 +1,465 @@
"""Apple Music 公共工具(对应 sdk/apple/util.ts,上游 music-lib/apple/apple.go 的移植基础层)。
* ``New(cookie)`` → auth_store 的 apple 记录:cookie 支持
``"media-user-token=xxx;storefront=cn;token=xxx"`` 形态。
* ``fetch_apple_token``:从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)。
当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi 开头而非上游正则写的 eyJh,
故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取。
* amp 请求(httpFetch 不自动跟 302,首页跳转用 fetch_follow 手动跟)。
* 模型转换与 ID 提取(字段映射同上游,apple_test.go 的断言逐条保留)。
"""
from __future__ import annotations
import asyncio
import base64
import json
import re
import time
from typing import Any
from urllib.parse import parse_qs, quote, urlencode, urljoin, urlsplit
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
APPLE_HOMEPAGE_URL = "https://music.apple.com"
APPLE_AMP_API_URL = "https://amp-api.music.apple.com"
DEFAULT_STOREFRONT = "us"
APPLE_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
)
#: JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
TOKEN_CACHE_TTL_MS = 12 * 60 * 60 * 1000
_cached_token = ""
_cached_at = 0.0
#: 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
_bypass_explicit_token = False
_token_lock = asyncio.Lock()
def parse_cookie_parts(cookie: str) -> dict[str, str]:
"""上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白。"""
result: dict[str, str] = {}
for part in str(cookie or "").split(";"):
trimmed = part.strip()
idx = trimmed.find("=")
if idx > 0:
result[trimmed[:idx].strip()] = trimmed[idx + 1 :].strip()
return result
def get_apple_config() -> dict[str, str]:
"""读取 apple 平台运行时配置(对应上游 New(cookie))。"""
cred = get_credentials("apple") or {}
parts = parse_cookie_parts(str(cred.get("cookie") or ""))
storefront = str(cred.get("storefront") or parts.get("storefront") or DEFAULT_STOREFRONT).strip()
return {
"storefront": storefront or DEFAULT_STOREFRONT,
"mediaUserToken": str(cred.get("mediaUserToken") or parts.get("media-user-token") or "").strip(),
"token": str(cred.get("token") or parts.get("token") or "").strip(),
}
def decode_apple_token_payload(token: str) -> dict[str, Any]:
"""解码 JWT payload(Apple token 为 ES256 JWT)。"""
try:
part = str(token or "").split(".")[1]
padded = part.replace("-", "+").replace("_", "/")
padded += "=" * (-len(padded) % 4)
data = json.loads(base64.b64decode(padded).decode("utf-8"))
return data if isinstance(data, dict) else {}
except (IndexError, ValueError, TypeError):
return {}
def is_apple_token_expired(token: str) -> bool:
try:
exp = float(decode_apple_token_payload(token).get("exp") or 0)
except (TypeError, ValueError):
exp = 0
return exp > 0 and exp * 1000 < time.time() * 1000 + 60 * 1000
def extract_apple_tokens(js: str) -> list[str]:
"""从 index JS 中提取所有 JWT 形态的候选 token(含引号前缀的主匹配 + 无前缀回退)。"""
pattern = r"(eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})"
found: list[str] = []
found.extend(m.group(1) for m in re.finditer(rf"""(?:=["']){pattern}""", str(js or "")))
if not found:
found.extend(m.group(1) for m in re.finditer(pattern, str(js or "")))
# 去重且保持顺序
return list(dict.fromkeys(found))
def pick_apple_token(js: str) -> str:
"""选择可用 token:优先 issuer=AMPWebPlay,其次第一个未过期;全部过期视为抓取失败。"""
tokens = extract_apple_tokens(js)
if not tokens:
return ""
alive = [token for token in tokens if not is_apple_token_expired(token)]
if not alive:
return ""
preferred = next(
(token for token in alive if str(decode_apple_token_payload(token).get("iss") or "") == "AMPWebPlay"), None
)
return preferred or alive[0] or ""
async def fetch_follow(url: str, headers: dict[str, str] | None = None, max_hops: int = 5) -> HttpResponse:
"""手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳)。"""
current = str(url)
response = None
for _hop in range(max_hops):
response = await http_fetch(current, headers=headers or {}, timeout=20_000)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
current = urljoin(current, location)
continue
return response
raise RuntimeError("apple: too many redirects")
def _response_text(response: HttpResponse) -> str:
if isinstance(response.body, str):
return response.body
if response.raw:
return response.raw.decode("utf-8", errors="replace")
return str(response.body or "")
async def fetch_apple_token() -> str:
"""上游 fetchAppleToken:首页 → index-legacy JS → bearer token。"""
home = await fetch_follow(APPLE_HOMEPAGE_URL, {"User-Agent": APPLE_UA})
if home.status_code != 200:
raise RuntimeError(f"apple: fetch homepage error: HTTP {home.status_code}")
html = _response_text(home)
# 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
match = re.search(r"/(assets/index-legacy[~-][^/\"']+\.js)", html)
if not match:
match = re.search(r"/(assets/index[~-][^/\"']+\.js)", html)
if not match:
raise RuntimeError("apple: index.js URI not found")
js_res = await fetch_follow(f"{APPLE_HOMEPAGE_URL}/{match.group(1)}", {"User-Agent": APPLE_UA})
if js_res.status_code != 200:
raise RuntimeError(f"apple: fetch index.js error: HTTP {js_res.status_code}")
token = pick_apple_token(_response_text(js_res))
if not token:
raise RuntimeError("apple: token not found in index.js")
return token
async def get_apple_token(force: bool = False) -> str:
"""获取 developer token(缓存 + JWT exp 校验 + 并发去重)。"""
global _cached_token, _cached_at
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
async with _token_lock:
# 双检:并发调用者拿到锁后直接复用前一次抓取结果
cfg = get_apple_config()
if not force and not _bypass_explicit_token and cfg["token"]:
return cfg["token"]
if (
not force
and _cached_token
and time.time() * 1000 - _cached_at < TOKEN_CACHE_TTL_MS
and not is_apple_token_expired(_cached_token)
):
return _cached_token
token = await fetch_apple_token()
_cached_token = token
_cached_at = time.time() * 1000
return token
def invalidate_apple_token() -> None:
"""清除缓存 token(401/403 时调用,下次请求会回源重取)。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = True
def reset_apple_token_state() -> None:
"""仅测试/调试用:重置内存态。"""
global _cached_token, _cached_at, _bypass_explicit_token
_cached_token = ""
_cached_at = 0.0
_bypass_explicit_token = False
def apple_amp_headers(token: str) -> dict[str, str]:
cfg = get_apple_config()
headers = {
"Authorization": f"Bearer {token}",
"Origin": APPLE_HOMEPAGE_URL,
"Referer": f"{APPLE_HOMEPAGE_URL}/",
"User-Agent": APPLE_UA,
"Accept": "application/json",
}
# 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
if cfg["mediaUserToken"]:
headers["Cookie"] = f"media-user-token={cfg['mediaUserToken']}"
return headers
def build_apple_amp_url(uri: str, params: dict[str, Any] | None = None) -> str:
"""拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL。"""
raw = str(uri or "")
if re.match(r"^https?://", raw, re.IGNORECASE):
base = raw
elif raw.startswith("/"):
base = APPLE_AMP_API_URL + raw
else:
base = f"{APPLE_AMP_API_URL}/{raw}"
pairs = [(key, str(value)) for key, value in (params or {}).items() if value is not None and value != ""]
if not pairs:
return base
query = urlencode(pairs)
return base + ("&" if "?" in base else "?") + query
def _amp_error_detail(response: HttpResponse) -> str:
body = response.body if isinstance(response.body, dict) else {}
errors = as_list(body.get("errors"))
first = as_dict(errors[0]) if errors else {}
return str(first.get("detail") or first.get("title") or "")
async def apple_amp_get_tracked(uri: str, params: dict[str, Any] | None = None) -> Any:
"""amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)。"""
url = build_apple_amp_url(uri, params)
async def attempt() -> HttpResponse:
token = await get_apple_token()
return await http_fetch(url, headers=apple_amp_headers(token), timeout=20_000)
response = await attempt()
if response.status_code in (401, 403):
invalidate_apple_token()
response = await attempt()
if not (200 <= response.status_code < 300):
detail = _amp_error_detail(response)
raise RuntimeError(f"apple amp-api HTTP {response.status_code}{f': {detail}' if detail else ''}")
return response.body
async def apple_amp_get(uri: str, params: dict[str, Any] | None = None) -> Any:
return await apple_amp_get_tracked(uri, params)
#: 拉取 relationships.tracks 全部分页时的页数上限(防死循环)
MAX_TRACK_PAGES = 20
async def fetch_all_track_resources(tracks: Any) -> list[Any]:
"""拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources)。"""
tracks = as_dict(tracks)
all_tracks: list[Any] = list(as_list(tracks.get("data")))
next_uri = str(tracks.get("next") or "").strip()
page = 0
while next_uri and page < MAX_TRACK_PAGES:
body = as_dict(await apple_amp_get_tracked(next_uri))
all_tracks.extend(as_list(body.get("data")))
next_uri = str(body.get("next") or "").strip()
page += 1
return all_tracks
def apple_artwork_url(artwork: Any, size: int = 600) -> str:
"""上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位。"""
artwork = as_dict(artwork)
url = str(artwork.get("url") or "")
if not url:
return ""
return url.replace("{w}", str(size)).replace("{h}", str(size))
def _round_number(value: Any) -> int:
"""JS Math.round 的等价实现(.5 向上取整)。"""
import math
try:
number = float(value or 0)
except (TypeError, ValueError):
number = 0.0
return math.floor(number + 0.5)
def apple_song_from_catalog(res: Any, default_album_id: str = "") -> dict[str, Any]:
"""上游 appleSongFromCatalog → 契约 Song 形状。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
previews = as_list(attr.get("previews"))
preview_url = str(as_dict(previews[0]).get("url") or "") if previews else ""
duration_sec = _round_number(float(attr.get("durationInMillis") or 0) / 1000)
return {
"songmid": str(res.get("id") or ""),
"name": attr.get("name") or "",
"singer": attr.get("artistName") or "",
"albumId": str(attr.get("albumId") or default_album_id or ""),
"albumName": attr.get("albumName") or "",
"interval": format_play_time(duration_sec),
"img": apple_artwork_url(attr.get("artwork"), 600) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "apple",
"link": attr.get("url") or "", # music.apple.com 网页地址
"previewUrl": preview_url, # 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
"duration": duration_sec,
"isrc": attr.get("isrc") or "",
}
def apple_album_to_playlist(res: Any) -> dict[str, Any]:
"""上游 appleAlbumToPlaylist → 契约歌单/专辑摘要。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
notes = as_dict(attr.get("editorialNotes"))
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("artistName") or "",
"description": notes.get("short") or "",
"link": attr.get("url") or "",
"source": "apple",
"extra": {
"release_date": attr.get("releaseDate") or "",
"record_label": attr.get("recordLabel") or "",
},
}
def apple_playlist_to_playlist(res: Any) -> dict[str, Any]:
"""上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游)。"""
res = as_dict(res)
attr = as_dict(res.get("attributes"))
description = as_dict(attr.get("description"))
notes = as_dict(attr.get("editorialNotes"))
desc = description.get("short") or description.get("standard") or notes.get("short") or notes.get("standard") or ""
return {
"id": str(res.get("id") or ""),
"name": attr.get("name") or "",
"cover": apple_artwork_url(attr.get("artwork"), 300),
"trackCount": int(attr.get("trackCount") or 0),
"creator": attr.get("curatorName") or "",
"description": desc,
"link": attr.get("url") or "",
"source": "apple",
}
def apple_web_url(kind: str, media_id: str, storefront: str = DEFAULT_STOREFRONT) -> str:
sf = str(storefront or DEFAULT_STOREFRONT)
return f"{APPLE_HOMEPAGE_URL}/{sf}/{kind}/{media_id}"
def is_apple_raw_id(value: str, media_type: str) -> bool:
"""上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字)。"""
if re.search(r"[/?#]", value) or "://" in value:
return False
if media_type == "playlist":
if value.startswith("pl."):
return re.fullmatch(r"[a-zA-Z0-9._-]+", value) is not None
return re.fullmatch(r"[a-zA-Z0-9_-]+", value) is not None
return re.fullmatch(r"\d+", value) is not None
def extract_apple_id_from_path(path: str, media_type: str) -> str:
"""上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id>。"""
trimmed = re.sub(r"^/+|/+$", "", str(path or ""))
if not trimmed:
return ""
segments = trimmed.split("/")
for index, segment in enumerate(segments):
if segment != media_type and segment != f"{media_type}s":
continue
if index + 2 < len(segments):
return segments[index + 2]
if index + 1 < len(segments):
return segments[index + 1]
return ""
def extract_apple_id(link: str, media_type: str) -> str:
"""上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底。"""
raw = str(link or "").strip()
if not raw:
return ""
if is_apple_raw_id(raw, media_type):
return raw
path = ""
query = ""
try:
parts = urlsplit(raw)
if parts.scheme or parts.netloc:
path = parts.path or ""
query = f"?{parts.query}" if parts.query else ""
except ValueError:
path, query = "", ""
if not path:
idx = raw.find("?")
path = raw[:idx] if idx >= 0 else raw
query = raw[idx:] if idx >= 0 else ""
if path:
if media_type == "song":
values = parse_qs(query.lstrip("?"), keep_blank_values=True).get("i")
song_id = values[0].strip() if values else ""
if song_id:
return song_id
from_path = extract_apple_id_from_path(path, media_type)
if from_path:
return from_path
patterns = [
rf"{media_type}/[^/]+/(\d+)",
rf"{media_type}s?/[^/]+/[^/]+/(\d+)",
r"/(\d+)$",
r"[?&]i=(\d+)",
]
if media_type == "playlist":
patterns.extend(
[
r"playlists?/[^/?#]+/([a-zA-Z0-9._-]+)",
r"playlists?/([a-zA-Z0-9._-]+)(?:[?#]|$)",
]
)
for pattern in patterns:
match = re.search(pattern, raw)
if match:
return match.group(1)
return ""
async def fetch_apple_song_resource(song_id: str, include: str = "") -> Any:
"""歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>)。"""
cfg = get_apple_config()
params: dict[str, Any] = {"extend": "extendedAssetUrls"}
if include:
params["include"] = include
body = as_dict(await apple_amp_get(f"/v1/catalog/{cfg['storefront']}/songs/{song_id}", params))
data = as_list(body.get("data"))
return data[0] if data else None
def quote_path_segment(value: str) -> str:
"""路径段转义(ID 基本是字母数字,保留以免未来出现特殊字符)。"""
return quote(str(value), safe="._-")
+96
View File
@@ -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,
)
+203
View File
@@ -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("无效的千千专辑链接")
+30
View File
@@ -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": ""}
+34
View File
@@ -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,
}
+41
View File
@@ -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"}
+48
View File
@@ -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)
+201
View File
@@ -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("无效的千千歌单链接")
+296
View File
@@ -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}"
+57
View File
@@ -0,0 +1,57 @@
"""哔哩哔哩(bili)平台模块(对应 sdk/bili/index.ts)。
上游 music-lib/bilibili 的移植,能力位:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单
(合集 ugc_season + 多分P稿件)/ 链接解析(BV/av、分P)/ 封面 / 扫码登录。
不实现(上游即未实现):专辑、歌词(空实现)、榜单、热搜、歌手、评论、个人歌单。
取流入口在 ``app/sdk/apis/bili.py``,songmid 约定 ``<bvid>|<cid>``。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.bili import lyric, music_search, parse, pic, playlist
from app.sdk.platforms.bili.util import parse_song_id
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现:apis 模块反向依赖本包的 util,顶层导入不必要地增加耦合。"""
from app.sdk.apis.bili import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def get_pic(song_info: dict[str, Any]) -> str:
return await pic.get_pic(song_info)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return "https://www.bilibili.com/"
try:
page = int(song_info.get("page") or 0)
except (TypeError, ValueError):
page = 0
return f"https://www.bilibili.com/video/{bvid}{f'?p={page}' if page > 1 else ''}"
MODULE = Platform(
id="bili",
name="哔哩哔哩",
music_search=music_search,
playlist=playlist,
parse=parse,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+14
View File
@@ -0,0 +1,14 @@
"""Bilibili 歌词(对应 sdk/bili/lyric.ts)。
上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串。
B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
返回统一结构 ``{"lyric": "", "tlyric": ""}``,让 /lyric/bili 有响应而不是 404。
"""
from __future__ import annotations
from typing import Any
async def get_lyric(_song_info: dict[str, Any] | None = None) -> dict[str, str]:
return {"lyric": "", "tlyric": ""}
+83
View File
@@ -0,0 +1,83 @@
"""Bilibili 搜索(对应 sdk/bili/musicSearch.ts,上游 bilibili/song.go 的 ``Search``)。
接口: x/web-interface/search/type?search_type=video
上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),
本移植保持一致,只是把逐个串行改成 4 并发;单条 view 失败按上游语义跳过该条。
实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;
风控时返回 -412/-352,会抛错由路由兜底。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import (
api_get,
as_dict,
as_list,
clean_title,
encode_uri_component,
fetch_view,
make_song,
map_limit,
to_number,
)
SEARCH_URL = "https://api.bilibili.com/x/web-interface/search/type"
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,B 站搜索无重试语义
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"))
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"])
pages = as_list(view.get("pages"))
if not pages:
return None
first = pages[0] if isinstance(pages[0], dict) else {}
root_title = clean_title(item.get("title"))
display_title = str(first.get("part") or "")
if not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
return make_song(
bvid=str(item["bvid"]),
cid=first.get("cid"),
name=display_title,
artist=str(item.get("author") or owner.get("name") or ""),
album=str(item["bvid"]),
duration=first.get("duration"),
cover=str(item.get("pic") or view.get("pic") or ""),
page=1,
)
songs = await map_limit(items, 4, one)
result = [song for song in songs if song]
total = int(to_number(data.get("numResults"))) or len(result)
num_pages = int(to_number(data.get("numPages")))
return {
"list": result,
"allPage": num_pages or p + (1 if len(result) >= size else 0),
"total": total,
"limit": size,
"source": "bili",
}
+93
View File
@@ -0,0 +1,93 @@
"""Bilibili 链接解析(对应 sdk/bili/parse.ts,上游 bilibili/song.go 的 ``Parse``)。
支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parse_playlist_link。
上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.playlist import parse_playlist_link
from app.sdk.platforms.bili.util import (
as_dict,
as_list,
extract_aid,
extract_bvid,
extract_page,
fetch_page_list,
fetch_view,
make_song,
normalize_cover,
)
async def parse_song(link: str) -> dict[str, Any]:
raw = str(link or "").strip()
bvid = extract_bvid(raw)
aid = extract_aid(raw)
if not bvid and not aid:
raise RuntimeError("无效的B站链接:未找到 BV/av 号")
view = await fetch_view(bvid or aid)
pages = as_list(view.get("pages"))
# 上游:合集或多分P视为歌单链接(Parse 只处理单P)
if view.get("ugc_season") or len(pages) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
if len(pages) <= 1:
try:
page_list = await fetch_page_list(view.get("bvid") or bvid)
if len(page_list) > 1:
raise RuntimeError("该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)")
except Exception as err:
if "歌单解析" in str(err):
raise
# 分P列表接口失败时按上游语义忽略
if not pages:
raise RuntimeError("未找到视频分P")
page = extract_page(raw)
if page > len(pages):
page = 1
target = pages[page - 1] if isinstance(pages[page - 1], dict) else {}
root_title = str(view.get("title") or "")
display_title = str(target.get("part") or "")
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
owner = as_dict(view.get("owner"))
cid = str(target.get("cid") if target.get("cid") is not None else "")
song = make_song(
bvid=str(view.get("bvid") or bvid),
cid=cid,
name=display_title,
artist=str(owner.get("name") or ""),
album=str(view.get("bvid") or bvid),
duration=target.get("duration"),
cover=normalize_cover(view.get("pic") or ""),
page=page,
)
# 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if cid:
try:
from app.sdk.apis.bili import get_audio_url
audio = await get_audio_url(str(song["bvid"]), cid)
urls = audio.get("urls") or []
if urls:
song["url"] = urls[0]
song["type"] = audio.get("type")
except Exception: # noqa: BLE001 - 忽略:元数据仍可返回
pass
return song
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单/合集链接解析(上游 bilibili/playlist.go 的 ``ParsePlaylist``)。"""
return await parse_playlist_link(link)
+21
View File
@@ -0,0 +1,21 @@
"""Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.bili.util import fetch_view, normalize_cover, parse_song_id
async def get_pic(song_info: dict[str, Any]) -> str:
direct = str(song_info.get("img") or "")
if direct.startswith(("http://", "https://")):
return direct
bvid = str(song_info.get("bvid") or "") or parse_song_id(song_info.get("songmid"))["bvid"]
if not bvid:
return ""
try:
view = await fetch_view(bvid)
return normalize_cover(view.get("pic") or "")
except Exception: # noqa: BLE001 - 与原文一致:封面取不到返回空串,不影响调用方
return ""
+318
View File
@@ -0,0 +1,318 @@
"""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站不支持歌单分类(上游未实现)")
+425
View File
@@ -0,0 +1,425 @@
"""Bilibili 公共请求助手(对应 sdk/bili/util.ts,上游 music-lib/bilibili 的共享逻辑)。
与上游的差异(2026-10-02 实测):
1) 上游硬编码的默认 cookie(buvid3=...; SESSDATA=your_sessdata;)当前会被 B 站风控:
带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
2) 请求层不跟随 302,取流的手动跟跳在 ``app/sdk/apis/bili.py`` 内处理。
3) 登录凭据来自 ``app/sdk/auth_store.py`` 的 bili 记录(扫码登录写入)。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0"
)
REFERER = "https://www.bilibili.com/"
HOME_URL = "https://www.bilibili.com/"
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def bili_headers(cookie: str = "") -> dict[str, str]:
"""上游 UserAgent/Referer 组合(所有接口统一携带)。"""
headers = {
"User-Agent": UA,
"Referer": REFERER,
"Accept": "application/json, text/plain, */*",
}
if cookie:
headers["Cookie"] = cookie
return headers
def get_login_cookie() -> str:
"""已保存的登录 Cookie(未登录返回空串)。"""
cred = get_credentials("bili") or {}
return str(cred.get("cookie") or "")
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
async def _fetch_home_cookies() -> str:
"""首页下发的新鲜 Cookie(http_fetch 保留了原始多值 Set-Cookie,同名头不会互相覆盖)。"""
resp = await http_fetch(HOME_URL, headers=bili_headers(), timeout=15_000)
jar = _cookie_map(resp.set_cookies)
return "; ".join(f"{key}={value}" for key, value in jar.items())
_anon_cookie = ""
_anon_lock = asyncio.Lock()
async def get_cookie() -> str:
"""请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘)。"""
saved = get_login_cookie()
if saved:
return saved
global _anon_cookie
if _anon_cookie:
return _anon_cookie
async with _anon_lock:
if _anon_cookie:
return _anon_cookie
try:
_anon_cookie = await _fetch_home_cookies()
except Exception: # noqa: BLE001 - 拿不到就用无 Cookie 请求(实测同样可用)
_anon_cookie = ""
return _anon_cookie
async def api_get(url: str, cookie: str | None = None) -> HttpResponse:
"""带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名)。"""
ck = await get_cookie() if cookie is None else cookie
return await http_fetch(url, headers=bili_headers(ck), timeout=15_000)
def normalize_cover(cover: Any) -> str:
"""封面协议补全(上游 normalizeCover)。"""
text = str(cover or "")
if not text:
return ""
return f"https:{text}" if text.startswith("//") else text
def clean_title(title: Any) -> str:
"""搜索标题去高亮标签(上游 cleanTitle)。"""
if not title:
return ""
return re.sub(r'<em class="keyword">|</em>', "", str(title))
def parse_song_id(songmid: Any) -> dict[str, str]:
"""songmid 约定:``<bvid>|<cid>``(与上游 ID 格式一致)。"""
raw = str(songmid if songmid is not None else "")
idx = raw.find("|")
if idx < 0:
return {"bvid": raw, "cid": ""}
return {"bvid": raw[:idx], "cid": raw[idx + 1 :]}
def extract_bvid(link: str) -> str:
"""从链接/id 提取 BV 号(兼容小写 bv)。"""
match = re.search(r"BV[0-9A-Za-z]+", str(link or ""), re.IGNORECASE)
return match.group(0) if match else ""
def extract_page(link: str) -> int:
"""从链接提取分P(上游 ``[?&]p=(\\d+)``)。"""
match = re.search(r"[?&]p=(\d+)", str(link or ""))
page = int(match.group(1)) if match else 0
return page if page > 0 else 1
def extract_aid(link: str) -> str:
"""从链接提取 av 号(上游未支持,任务要求兼容)。"""
match = re.search(r"\bav(\d+)\b", str(link or ""), re.IGNORECASE)
return match.group(1) if match else ""
def to_number(value: Any) -> float:
"""JS 的 Number(value):空值/非数字一律 0。"""
try:
return float(value)
except (TypeError, ValueError):
return 0.0
async def map_limit(items: list[Any], limit: int, fn: Any) -> list[Any]:
"""并发映射(限制并发数,单条失败返回 None)。"""
source = list(items or [])
out: list[Any] = [None] * len(source)
if not source:
return out
size = max(1, min(int(limit or 1), len(source)))
cursor = 0
async def worker() -> None:
nonlocal cursor
while True:
index = cursor
cursor += 1
if index >= len(source):
return
try:
out[index] = await fn(source[index], index)
except Exception: # noqa: BLE001 - 单条失败按上游语义跳过
out[index] = None
await asyncio.gather(*(worker() for _ in range(size)))
return out
def make_song(
*,
bvid: str,
cid: Any,
name: str = "",
artist: str = "",
album: str = "",
duration: Any = 0,
cover: str = "",
page: int = 1,
) -> dict[str, Any]:
"""统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid)。"""
seconds = to_number(duration)
link = f"https://www.bilibili.com/video/{bvid}" + (f"?p={page}" if page > 1 else "")
return {
"songmid": f"{bvid}|{cid}",
"name": name or "",
"singer": artist or "",
"albumId": bvid or "",
"albumName": album or bvid or "",
"interval": format_play_time(seconds) if seconds else 0,
"img": normalize_cover(cover) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "bili",
"bvid": bvid,
"cid": str(cid if cid is not None else ""),
"page": page,
"link": link,
}
def assert_api_ok(what: str, status_code: int, body: Any) -> None:
"""B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误。"""
if status_code != 200 or not isinstance(body, dict) or body.get("code") != 0:
code = body.get("code") if isinstance(body, dict) else None
message = body.get("message") if isinstance(body, dict) else None
reason = code if code is not None else f"HTTP {status_code}"
raise RuntimeError(f"B站{what}获取失败({reason}{f': {message}' if message else ''})")
async def fetch_view(video_id: Any) -> dict[str, Any]:
"""视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点。"""
raw = str(video_id or "").strip()
if not raw:
raise RuntimeError("B站视频 id 为空")
if re.match(r"^BV", raw, re.IGNORECASE):
query = f"bvid={encode_uri_component(raw)}"
else:
query = f"aid={encode_uri_component(re.sub('^av', '', raw, flags=re.IGNORECASE))}"
resp = await api_get(f"https://api.bilibili.com/x/web-interface/view?{query}")
assert_api_ok("视频信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
if not isinstance(data, dict):
raise RuntimeError("B站视频信息获取失败(data 为空)")
return data
async def fetch_page_list(bvid: str) -> list[Any]:
"""分P列表(x/player/pagelist),返回 data 数组。"""
resp = await api_get(f"https://api.bilibili.com/x/player/pagelist?bvid={encode_uri_component(bvid)}")
assert_api_ok("分P列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
return data if isinstance(data, list) else []
def _season_page_url(mid: Any, season_id: Any, page_num: int, page_size: int) -> str:
return (
"https://api.bilibili.com/x/space/ugc/season?"
f"mid={mid}&season_id={season_id}&page_num={page_num}&page_size={page_size}"
)
async def fetch_season_archive_index(mid: Any, season_id: Any) -> dict[str, Any]:
"""合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex)。
该接口现已 404,调用方需容错;返回 ``{"index", "title", "cover"}``,
index 以 ``bvid:<bvid>`` / ``cid:<cid>`` 为键。
"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
index: dict[str, dict[str, Any]] = {}
season_title = ""
season_cover = ""
processed = 0
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集信息", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict):
continue
meta = {
"title": str(arc.get("title") or ""),
"cover": normalize_cover(arc.get("cover") or ""),
"duration": to_number(arc.get("duration")),
}
if arc.get("bvid"):
index[f"bvid:{arc['bvid']}"] = meta
if arc.get("cid"):
index[f"cid:{arc['cid']}"] = meta
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return {"index": index, "title": season_title, "cover": season_cover}
def build_songs_from_pages(
bvid: str, root_title: str, author: str, cover: str, pages: list[Any]
) -> list[dict[str, Any]]:
"""由分P数组构造歌曲(上游 buildSongsFromPages)。"""
songs: list[dict[str, Any]] = []
for i, page in enumerate(pages or []):
part = str(page.get("part") or "") if isinstance(page, dict) else ""
display_title = part
if len(pages) == 1 and not display_title:
display_title = root_title
elif display_title != root_title:
display_title = f"{root_title} - {display_title}"
songs.append(
make_song(
bvid=bvid,
cid=page.get("cid") if isinstance(page, dict) else None,
name=display_title,
artist=author,
album=bvid,
duration=page.get("duration") if isinstance(page, dict) else 0,
cover=cover,
page=i + 1,
)
)
return songs
def build_songs_from_season_sections(
sections: list[Any],
season_title: str,
season_cover: str,
artist_name: str,
archive_index: dict[str, dict[str, Any]] | None = None,
) -> list[dict[str, Any]]:
"""由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections)。"""
index = archive_index or {}
songs: list[dict[str, Any]] = []
artist = artist_name or season_title
for sec in sections or []:
if not isinstance(sec, dict):
continue
for ep in sec.get("episodes") or []:
if not isinstance(ep, dict):
continue
cover = str(ep.get("cover") or "")
meta = index.get(f"bvid:{ep.get('bvid')}") or (index.get(f"cid:{ep.get('cid')}") if ep.get("cid") else None)
if not cover:
arc = as_dict(ep.get("arc"))
cover = str(arc.get("pic") or "")
if not cover:
cover = season_cover or (meta or {}).get("cover", "")
duration = to_number(ep.get("duration"))
if not duration:
page = as_dict(ep.get("page"))
duration = to_number(page.get("duration"))
if not duration:
arc = as_dict(ep.get("arc"))
duration = to_number(arc.get("duration"))
if not duration:
duration = to_number((meta or {}).get("duration"))
page = as_dict(ep.get("page"))
arc = as_dict(ep.get("arc"))
name = str(ep.get("title") or arc.get("title") or page.get("part") or (meta or {}).get("title") or "")
songs.append(
make_song(
bvid=str(ep.get("bvid") or ""),
cid=ep.get("cid"),
name=name,
artist=artist,
album=str(ep.get("bvid") or ""),
duration=duration,
cover=cover,
page=1,
)
)
return songs
async def fetch_season_songs(mid: Any, season_id: Any) -> list[dict[str, Any]]:
"""合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错)。"""
if not mid or not season_id:
raise RuntimeError("合集信息无效")
all_songs: list[dict[str, Any]] = []
processed = 0
season_title = ""
season_cover = ""
page_num = 1
page_size = 30
while True:
resp = await api_get(_season_page_url(mid, season_id, page_num, page_size))
assert_api_ok("合集列表", resp.status_code, resp.body)
data = resp.body.get("data") if isinstance(resp.body, dict) else None
data = data if isinstance(data, dict) else {}
season = as_dict(data.get("season"))
if not season_title:
season_title = str(season.get("title") or "")
if not season_cover:
season_cover = str(season.get("cover") or "")
archives = as_list(data.get("archives"))
if not archives:
break
for arc in archives:
if not isinstance(arc, dict) or not arc.get("cid"):
continue
all_songs.append(
make_song(
bvid=str(arc.get("bvid") or ""),
cid=arc.get("cid"),
name=str(arc.get("title") or ""),
artist="",
album=season_title or str(arc.get("bvid") or ""),
duration=arc.get("duration"),
cover=normalize_cover(arc.get("cover") or season_cover),
page=1,
)
)
processed += len(archives)
page_info = as_dict(data.get("page"))
total = int(to_number(page_info.get("total")))
if not total or processed >= total:
break
page_num += 1
if page_num > 100:
break
return all_songs
+68
View File
@@ -0,0 +1,68 @@
"""5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块(对应 sdk/5sing/index.ts)。
上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
榜单/热搜/歌手/评论(上游均无)。
取流入口在 ``app/sdk/apis/fivesing.py``;songmid 约定 ``<songId>|<songType>``(如 "932557|yc")。
登录:上游 New(cookie) 的 cookie 会随请求发送;本移植从 auth_store 的 5sing 记录读取(无则匿名)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.fivesing import lyric, music_search, parse, playlist
from app.sdk.platforms.fivesing.util import build_song_link, extract_song_id, get_status
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
return await lyric.get_lyric(song_info)
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""延迟导入取流实现,避免 apis → 本包 util 的初始化顺序耦合。"""
from app.sdk.apis.fivesing import get_music_url
return await get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
if song_info.get("link"):
return str(song_info["link"])
ids = extract_song_id(song_info.get("songmid") or song_info.get("id"), song_info.get("songType"))
if not ids["songID"] or not ids["songType"]:
return "http://5sing.kugou.com/"
return build_song_link(ids["songType"], ids["songID"])
class _UserPlaylist:
"""上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义。"""
@staticmethod
async def get_list(_page: int = 1, _limit: int = 20) -> dict[str, Any]:
raise RuntimeError("user playlists not supported")
user_playlist = _UserPlaylist()
def status() -> dict[str, Any]:
return get_status()
MODULE = Platform(
id="5sing",
name="5sing",
music_search=music_search,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=_get_music_url,
get_lyric=get_lyric,
get_music_detail_page_url=get_music_detail_page_url,
)
+41
View File
@@ -0,0 +1,41 @@
"""5sing 歌词(对应 sdk/5sing/lyric.ts,上游 fivesing/lyric.go 的 GetLyrics)。
接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
→ data.dynamicWords 即 LRC 文本。
上游 ID 约定:model.Song.ID = "<songid>|<songtype>",从 songmid 的 "|" 拆分
(路由层只透传 songmid),也接受 songInfo.songType 字段。
统一接口:返回 ``{lyric, tlyric: ''}``(5sing 无翻译歌词)。
实况(2026-10-23 匿名实测):newget 响应不再包含 dynamicWords(搜索前 10 条与多个关键词均如此,
TS 版同样结果),因此此刻匿名取歌词会如实抛 "lyrics not found";绑定 cookie 后是否有变化尚未验证。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_dynamic_lyric
from app.sdk.platforms.fivesing.util import extract_song_id, is_foreign_source
async def get_lyric_text(song_id: str, song_type: str) -> str:
"""直接返回歌词文本(内部/测试用)。"""
lyric = await fetch_dynamic_lyric(song_id, song_type)
if not lyric:
raise RuntimeError("lyrics not found")
return lyric
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
if is_foreign_source(song_info):
raise RuntimeError("source mismatch")
ids = extract_song_id(
song_info.get("songmid") or song_info.get("id") or song_info.get("songid"), song_info.get("songType")
)
if not ids["songID"]:
raise RuntimeError("invalid id")
lyric = await fetch_dynamic_lyric(ids["songID"], ids["songType"])
if not lyric:
raise RuntimeError("lyrics not found")
return {"lyric": lyric, "tlyric": ""}
+118
View File
@@ -0,0 +1,118 @@
"""5sing 歌曲信息 / 取流底层(对应 sdk/5sing/musicInfo.ts)。
链路(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink):
取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址;
上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
get_first_valid,
kind_name,
)
QUALITY_TIERS = ("sq", "hq", "lq")
def _tier_url(data: dict[str, Any], tier: str) -> str:
"""data 里某档位的可用地址(主地址 → backup)。"""
return get_first_valid(data.get(f"{tier}url"), data.get(f"{tier}url_backup"))
async def fetch_audio_link(song_id: str, song_type: str, prefer_low: bool = False) -> dict[str, str]:
"""上游 fetchAudioLink:仅获取音频链接。"""
url = (
f"{MOBILE_API}/song/getSongUrl?songid={encode_uri_component(song_id)}"
f"&songtype={encode_uri_component(song_type)}"
)
resp = await fetch_5sing_json(url)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 取流失败(HTTP {resp.status_code})")
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
if code != 1000:
# 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
message = body.get("message")
raise RuntimeError(f"5sing 取流失败({message})" if message else "api returned error code")
data = as_dict(body.get("data"))
order = tuple(reversed(QUALITY_TIERS)) if prefer_low else QUALITY_TIERS
for tier in order:
hit = _tier_url(data, tier)
if hit and hit.startswith(("http://", "https://")):
return {"type": tier, "url": hit}
raise RuntimeError("no valid download url found")
async def fetch_new_get(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象)。"""
url = f"{MOBILE_API}/song/newget?songid={encode_uri_component(song_id)}&songtype={encode_uri_component(song_type)}"
resp = await fetch_5sing_json(url)
if resp.status_code != 200 or not isinstance(resp.body, dict):
raise RuntimeError(f"5sing 歌曲信息获取失败(HTTP {resp.status_code})")
data = resp.body.get("data")
return data if isinstance(data, dict) else {}
async def fetch_dynamic_lyric(song_id: str, song_type: str) -> str:
"""上游 newget 歌词:data.dynamicWords(LRC 文本)。"""
data = await fetch_new_get(song_id, song_type)
return str(data.get("dynamicWords") or "")
async def fetch_song_info(song_id: str, song_type: str) -> dict[str, Any]:
"""上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。"""
hit = await fetch_audio_link(song_id, song_type)
name = ""
artist = ""
cover = ""
try:
data = await fetch_new_get(song_id, song_type)
name = str(data.get("SN") or "")
user = as_dict(data.get("user"))
artist = str(user.get("NN") or "")
cover = str(user.get("I") or "")
except Exception: # noqa: BLE001 - 上游 newget 失败时继续(不阻断)
pass
if not name:
name = f"5sing_{song_type}_{song_id}"
return {
"songmid": f"{song_id}|{song_type}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": cover or None,
"lrc": None,
"types": [],
"_types": {},
"url": hit["url"],
"type": hit["type"],
"link": build_song_link(song_type, song_id),
"songType": song_type,
"typeName": kind_name(song_type),
"source": "5sing",
}
def song_interval(song_size: Any) -> int:
"""时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式)。"""
return estimate_duration(song_size)
@@ -0,0 +1,73 @@
"""5sing 歌曲搜索(对应 sdk/5sing/musicSearch.ts,上游 fivesing/song.go 的 Search)。
接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;
本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import (
SEARCH_API,
build_song_link,
encode_uri_component,
estimate_duration,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
def map_search_song(item: dict[str, Any]) -> dict[str, Any]:
"""上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing')。"""
song_type = str(item.get("typeEname") or "")
duration = estimate_duration(item.get("songSize"))
return {
"songmid": f"{item.get('songId', '')}|{song_type}",
"name": remove_em_tags(unescape_html(item.get("songName"))),
"singer": remove_em_tags(unescape_html(item.get("singer") or item.get("nickName"))),
"albumId": "",
"albumName": "",
"interval": format_play_time(duration) if duration > 0 else 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
# 统一种类字段(上游 typeEname / typeName)
"songType": song_type,
"typeName": str(item.get("typeName") or kind_name(song_type)),
"size": int(item.get("songSize") or 0),
"link": build_song_link(song_type, item.get("songId")),
"source": "5sing",
}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,重试已在 fetch_5sing_json 内
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "allPage": 0, "total": 0, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=0"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
songs = [map_search_song(item) for item in raw_list[:page_size] if isinstance(item, dict)]
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(songs)
# 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
all_page = int(page_info.get("totalPages") or 0) or (page_no + 1 if len(songs) >= page_size else page_no)
return {"list": songs, "allPage": all_page, "total": total, "limit": page_size, "source": "5sing"}
+25
View File
@@ -0,0 +1,25 @@
"""5sing 链接解析(对应 sdk/5sing/parse.ts)。
歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetch_song_info(直链 + newget 元数据,无直链即报错)
歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId)
专辑: 上游无专辑概念(不实现)
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.fivesing.music_info import fetch_song_info
from app.sdk.platforms.fivesing.playlist import parse_playlist_link
from app.sdk.platforms.fivesing.util import extract_song_link
async def parse_song(link: str) -> dict[str, Any]:
parsed = extract_song_link(link)
if not parsed:
raise RuntimeError("invalid 5sing link")
return await fetch_song_info(parsed["songID"], parsed["songType"])
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+282
View File
@@ -0,0 +1,282 @@
"""5sing 歌单(对应 sdk/5sing/playlist.ts,上游 fivesing/fivesing.go + playlist.go)。
搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/...
上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植并发 5。
详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块
③ 数字 userId 页会 302 到个性域名页,fetch_5sing 已手动跟跳。
分类/个人歌单:上游显式返回不支持,本移植保留同样语义(抛错)。
链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名与省略 user 段,ID 以 API 反查为准。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.fivesing.util import (
MOBILE_API,
SEARCH_API,
build_playlist_link,
build_song_link,
encode_uri_component,
extract_playlist_link,
fetch_5sing,
fetch_5sing_json,
kind_name,
remove_em_tags,
unescape_html,
)
async def get_categories() -> dict[str, Any]:
"""上游 GetPlaylistCategories:显式不支持。"""
raise RuntimeError("playlist categories not supported")
async def get_category_playlists(_category_id: str, _page: int = 1, _limit: int = 30) -> dict[str, Any]:
"""上游 GetCategoryPlaylists:显式不支持。"""
raise RuntimeError("playlist categories not supported")
def _map_playlist_song(song_id: str, kind: str, name: str, artist: str) -> dict[str, Any]:
"""歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状。"""
return {
"songmid": f"{song_id}|{kind}",
"name": name,
"singer": artist,
"albumId": "",
"albumName": "",
"interval": 0,
"img": None,
"lrc": None,
"types": [],
"_types": {},
"songType": kind,
"typeName": kind_name(kind),
"link": build_song_link(kind, song_id),
"source": "5sing",
}
def parse_songs_from_html(html_content: str) -> list[dict[str, Any]]:
"""上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
块正则 ``<li class="p_rel">([\\s\\S]*?)</li>``;块内 href 提取 yc/fc/bz 与 id,
s_soner 提取歌手(上游的拼写错误 soner 保留)。
"""
blocks = re.findall(r'<li class="p_rel">([\s\S]*?)</li>', str(html_content or ""))
if not blocks:
raise RuntimeError("no songs found in playlist html (structure mismatch)")
# 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
song_re = re.compile(r'href="(?:(?:https?:)?//)?5sing\.kugou\.com/(yc|fc|bz)/(\d+)\.html"[^>]*>([^<]+)</a>')
artist_re = re.compile(r'class="s_soner[^"]*".*?>([^<]+)</a>')
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for block in blocks:
song_match = song_re.search(block)
if not song_match:
continue
kind, song_id, raw_name = song_match.group(1), song_match.group(2), song_match.group(3)
artist = "Unknown"
artist_match = artist_re.search(block)
if artist_match:
artist = artist_match.group(1)
unique_key = f"{kind}|{song_id}"
if unique_key in seen:
continue
seen.add(unique_key)
name = remove_em_tags(unescape_html(raw_name)).strip()
artist = remove_em_tags(unescape_html(artist)).strip()
songs.append(_map_playlist_song(song_id, kind, name, artist))
return songs
async def fetch_creator_name(playlist_id: str) -> str:
"""上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名。"""
if not playlist_id:
return ""
try:
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=user"
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return ""
data = resp.body.get("data")
if not isinstance(data, dict):
return ""
user = as_dict(data.get("user"))
return str(user.get("NN") or "")
except Exception: # noqa: BLE001 - 反查失败保留 ID 兜底
return ""
def _map_playlist_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐)。"""
playlist_id = str(item.get("songListId") or "")
user_id = str(item.get("userId") or "")
desc = remove_em_tags(unescape_html(item.get("content")))
creator = str(item.get("userName") or "") or (f"ID: {user_id}" if user_id else "")
return {
"id": playlist_id,
"name": remove_em_tags(unescape_html(item.get("title"))),
"cover": str(item.get("pictureUrl") or ""),
"trackCount": int(item.get("songCnt") or 0),
"playCount": int(item.get("playCount") or 0),
"creator": creator,
"creatorId": user_id,
# 上游怪癖:content 为字符串 "0" 时视为空简介
"description": "" if desc == "0" else desc,
"link": build_playlist_link(user_id, playlist_id),
"source": "5sing",
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""上游 SearchPlaylist(type=1),补创建者名(并发 5)。"""
word = str(keyword or "").strip()
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
empty = {"list": [], "total": 0, "page": page_no, "limit": page_size, "source": "5sing"}
if not word:
return empty
url = f"{SEARCH_API}?keyword={encode_uri_component(word)}&sort=1&page={page_no}&filter=0&type=1"
resp = await fetch_5sing_json(url)
if resp.status_code != 200:
raise RuntimeError(f"5sing 歌单搜索失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
raw_list = as_list(body.get("list"))
playlists = [_map_playlist_summary(item) for item in raw_list[:page_size] if isinstance(item, dict)]
# 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
targets = [pl for pl in playlists if pl["creatorId"] and pl["creator"] == f"ID: {pl['creatorId']}"]
for start in range(0, len(targets), 5):
chunk = targets[start : start + 5]
async def fill(pl: dict[str, Any]) -> None:
name = await fetch_creator_name(pl["id"])
if name:
pl["creator"] = name
await asyncio.gather(*(fill(pl) for pl in chunk))
page_info = as_dict(body.get("pageInfo"))
total = int(page_info.get("totalCount") or 0) or len(playlists)
return {"list": playlists, "total": total, "page": page_no, "limit": page_size, "source": "5sing"}
async def fetch_playlist_all(playlist_id_raw: str) -> dict[str, Any]:
"""元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全)。"""
playlist_id = str(playlist_id_raw or "").strip()
if not playlist_id:
raise RuntimeError("invalid 5sing playlist id")
# 1) API 元数据(标题/封面/统计/UserID)
resp = await fetch_5sing_json(
f"{MOBILE_API}/song/getsonglist?id={encode_uri_component(playlist_id)}&songfields=ID,user"
)
if resp.status_code != 200:
raise RuntimeError(f"fetch info failed: HTTP {resp.status_code}")
# 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
body = as_dict(resp.body)
data = body.get("data")
if not isinstance(data, dict):
raise RuntimeError("playlist info not found or invalid (api returned empty list)")
user = as_dict(data.get("user"))
user_id = str(user.get("ID") or "")
if not user_id:
raise RuntimeError("playlist user not found or invalid id")
playlist = {
"id": playlist_id,
"name": str(data.get("T") or ""),
"cover": str(data.get("P") or ""),
"trackCount": int(data.get("E") or 0),
"playCount": int(data.get("H") or 0),
"creator": str(user.get("NN") or ""),
"description": str(data.get("C") or ""),
"link": build_playlist_link(user_id, playlist_id),
"userId": user_id,
"source": "5sing",
}
# 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch_5sing 已跟跳)
page = await fetch_5sing(playlist["link"])
if page.status_code != 200:
raise RuntimeError(f"fetch html failed: HTTP {page.status_code}")
# 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
songs: list[dict[str, Any]] = []
try:
songs = parse_songs_from_html(str(page.body if isinstance(page.body, str) else ""))
except Exception: # noqa: BLE001 - 结构不匹配时返回空列表
songs = []
return {"playlist": playlist, "songs": songs}
def _playlist_info(playlist: dict[str, Any]) -> dict[str, Any]:
return {
"id": playlist["id"],
"name": playlist["name"],
"img": playlist["cover"],
"desc": playlist["description"],
"author": playlist["creator"],
"link": playlist["link"],
"trackCount": playlist["trackCount"],
"playCount": playlist["playCount"],
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E)。"""
detail = await fetch_playlist_all(playlist_id)
playlist, songs = detail["playlist"], detail["songs"]
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
start = (page_no - 1) * page_size
total = len(songs)
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs[start : start + page_size],
"page": page_no,
"limit": page_size,
"total": total,
"allPage": math.ceil(total / page_size) or 1,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""上游 GetPlaylistSongs:仅返回曲目列表(全量)。"""
detail = await fetch_playlist_all(playlist_id)
return detail["songs"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""上游 ParsePlaylist:歌单链接 → 详情(全量曲目)。"""
parsed = extract_playlist_link(link)
if not parsed:
raise RuntimeError("invalid 5sing playlist link")
detail = await fetch_playlist_all(parsed["playlistId"])
playlist, songs = detail["playlist"], detail["songs"]
return {
"source": "5sing",
"info": _playlist_info(playlist),
"list": songs,
"page": 1,
"limit": len(songs) or 1,
"total": len(songs),
"allPage": 1,
}
+196
View File
@@ -0,0 +1,196 @@
"""5sing 公共工具(对应 sdk/5sing/util.ts,上游 fivesing/fivesing.go 的等价实现)。
平台 id 统一为 ``5sing``(上游 Go 包名 fivesing,model.Source 用 "fivesing";本仓库契约要求 5sing)。
上游 ``New(cookie)`` 的 cookie 实际参与请求(搜索 / getSongUrl / newget / 歌单页),
这里从 auth_store 的 ``5sing`` 记录读取(无则匿名)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
UserAgent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
)
#: 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302)
MOBILE_API = "http://mobileapi.5sing.kugou.com"
#: 搜索接口(type=0 单曲 / type=1 歌单)
SEARCH_API = "http://search.5sing.kugou.com/home/json"
#: 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏)
SONG_KINDS: dict[str, str] = {"yc": "原创", "fc": "翻唱", "bz": "伴奏"}
HTML_ENTITIES: dict[str, str] = {
"amp": "&",
"lt": "<",
"gt": ">",
"quot": '"',
"apos": "'",
"nbsp": " ",
"copy": "©",
"reg": "®",
"hellip": "…",
"mdash": "—",
"ndash": "–",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def get_first_valid(*urls: Any) -> str:
"""上游 getFirstValid:返回第一个非空字符串(空串视为无效)。"""
for url in urls:
if url:
return str(url)
return ""
def remove_em_tags(text: Any) -> str:
"""去掉搜索结果里 ``<em class="keyword">`` 高亮标签。"""
return str(text if text is not None else "").replace('<em class="keyword">', "").replace("</em>", "").strip()
def unescape_html(text: Any) -> str:
"""Go html.UnescapeString 的等价实现(常用实体 + 数字实体)。"""
source = str(text if text is not None else "")
def repl(match: re.Match[str]) -> str:
entity = match.group(1)
try:
if entity.startswith(("#x", "#X")):
return chr(int(entity[2:], 16))
if entity.startswith("#"):
return chr(int(entity[1:], 10))
except ValueError:
return match.group(0)
return HTML_ENTITIES.get(entity.lower(), match.group(0))
return re.sub(r"&(#x?[0-9a-fA-F]+|[a-zA-Z]+);", repl, source)
def get_5sing_cookie() -> str:
"""上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用)。"""
cred = get_credentials("5sing") or {}
return str(cred.get("cookie") or "")
def get_status() -> dict[str, Any]:
"""登录态(凭据存在性判断)。"""
cred = get_credentials("5sing") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
def is_foreign_source(song_info: dict[str, Any]) -> bool:
"""是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查)。"""
source = song_info.get("source")
return bool(source) and source != "5sing"
async def fetch_5sing(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""带 Cookie/UA 的请求;httpFetch 不跟随 302,这里手动跟跳最多 3 次。
5sing 老链接会 http→https / 数字 userId → 个性域名跳转,必须跟。
"""
cookie = get_5sing_cookie()
all_headers = {"User-Agent": UserAgent}
if cookie:
all_headers["Cookie"] = cookie
if headers:
all_headers.update({str(key): str(value) for key, value in headers.items()})
current = str(url)
response = None
for _hop in range(4):
response = await http_fetch(current, headers=all_headers, timeout=timeout_ms)
status = response.status_code or 0
location = response.headers.get("location", "")
if 300 <= status < 400 and location:
from urllib.parse import urljoin
try:
current = urljoin(current, location)
except ValueError:
return response
continue
return response
return response
async def fetch_5sing_json(url: str, *, headers: dict[str, str] | None = None, timeout_ms: int = 15_000) -> Any:
"""JSON 接口带退避重试(手机端 API 频率控制:code 13 / "操作太频繁")。"""
response = None
for attempt in range(3):
response = await fetch_5sing(url, headers=headers, timeout_ms=timeout_ms)
body = response.body if isinstance(response.body, dict) else {}
try:
code = int(body.get("code") or 0)
except (TypeError, ValueError):
code = 0
message = str(body.get("message") or "")
if (code == 13 or "太频繁" in message) and attempt < 2:
await asyncio.sleep(0.8 * (attempt + 1))
continue
return response
return response
def build_song_link(song_type: str, song_id: Any) -> str:
"""歌曲页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{song_type}/{song_id}.html" if song_type and song_id else ""
def build_playlist_link(user_id: Any, playlist_id: str) -> str:
"""歌单页链接(上游 Link 拼法)。"""
return f"http://5sing.kugou.com/{user_id}/dj/{playlist_id}.html" if user_id and playlist_id else ""
def extract_song_id(songmid: Any, song_type_fallback: Any = None) -> dict[str, str]:
"""解析歌曲的 ``<songid>|<songtype>`` 约定(兼容只给 songType 字段的调用)。"""
raw = str(songmid if songmid is not None else "")
parts = raw.split("|")
id_part = parts[0] if parts else ""
type_part = parts[1] if len(parts) > 1 else ""
return {"songID": id_part or "", "songType": str(type_part or song_type_fallback or "")}
def extract_song_link(link: Any) -> dict[str, str] | None:
"""上游 Parse 的正则:5sing.kugou.com/(\\w+)/(\\d+).html(专辑/歌手页不匹配)。"""
match = re.search(r"5sing\.kugou\.com/(\w+)/(\d+)\.html", str(link if link is not None else ""))
if not match:
return None
return {"songType": match.group(1), "songID": match.group(2)}
def extract_playlist_link(link: Any) -> dict[str, str] | None:
"""歌单链接解析(放宽为任意单段 userId,含个性域名与省略 user 段)。"""
match = re.search(
r"5sing\.kugou\.com/(?:([^/?#]+)/)?dj/([a-zA-Z0-9]+)\.html", str(link if link is not None else "")
)
if not match:
return None
return {"userId": match.group(1) or "", "playlistId": match.group(2)}
def estimate_duration(song_size: Any) -> int:
"""上游 Search 的时长估算:songSize*8/320000(320kbps 假设;0 返回 0)。"""
try:
size = float(song_size or 0)
except (TypeError, ValueError):
size = 0.0
return math.trunc((size * 8) / 320000) if size > 0 else 0
def kind_name(song_type: str) -> str:
"""作品类型中文名(typeName 缺失时兜底)。"""
return SONG_KINDS.get(song_type, "")
+85
View File
@@ -0,0 +1,85 @@
"""JOOX(joox)平台模块(对应 sdk/joox/index.ts)。
匿名可用面(2026-10 实测,www 站点需 X-Forwarded-For 东南亚 IP 绕过地区拦截):
搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+链接解析)/ 歌词 / 链接解析(歌曲)/
取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
通道:
* 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(**无签名**)
* 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(**需 md5 签名**,见 util.joox_sign)
* 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
* 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
⚠ 网络连通性实况(本机出口 IP 非东南亚):
* cache.api.joox.com 与 api.joox.com 的 JSON 接口全部可直连;
* www.joox.com 的 SSR 页面返回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__,
因此 ``playlist.get_categories`` / ``playlist.get_category_playlists`` 与专辑·歌单的页面兜底
在当前网络下会如实抛错(不返回假数据)。这与 TS 注释一致:TS 也依赖伪造 X-Forwarded-For,
本移植带的是同一个头(``util.JOOX_XFF``),能否生效取决于上游是否采信,实测本机未被采信。
"""
from __future__ import annotations
from typing import Any, ClassVar
from app.sdk.apis.joox import get_music_url
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.joox import album, lyric, music_search, parse, playlist
from app.sdk.platforms.joox.music_info import get_track_detail
from app.sdk.platforms.joox.util import joox_song_link, normalize_joox_id, pick_joox_image
class JOOXSongList:
"""契约别名 ``songList``(对应 TS index.ts 里的内联 shim)。
JOOX 没有独立的「排行榜/歌曲列表」接口:``getTags`` 即歌单分类,``getList`` 即某分类下的歌单。
``sort_list`` 只有「全部」一项,因此路由透传的 ``sort_id`` 恒为空串,``tag_id`` 才是分类号。
TS 的 ``getList(sortId, tagId, page, limit=30)`` 中 ``limit`` 固定在内部,路由不透传。
"""
sort_list: ClassVar[list[dict[str, str]]] = [{"name": "全部", "id": ""}]
@staticmethod
async def get_tags() -> dict[str, Any]:
return await playlist.get_categories()
@staticmethod
async def get_list(sort_id: str = "", tag_id: str = "", page: int = 1) -> dict[str, Any]:
return await playlist.get_category_playlists(str(tag_id or sort_id or ""), page, 30)
async def _get_pic(song_info: dict[str, Any]) -> str | None:
"""封面(对应 TS index.ts 的 getPic):走 openjoox2 track 详情后挑图,取不到返回 None。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or "")
detail = await get_track_detail(song_id)
return pick_joox_image(detail.get("images")) or None
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return joox_song_link(song_info.get("songmid") or song_info.get("id") or "")
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断;无在线校验接口)。"""
cred = get_credentials("joox")
if cred and cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="joox",
name="JOOX",
music_search=music_search,
song_list=JOOXSongList,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=_get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
View File
Whitespace-only changes.
+221
View File
@@ -0,0 +1,221 @@
"""JOOX 专辑(对应 sdk/joox/album.ts,上游 joox/album.go 移植 + 新版 openjoox2 通道)。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``section_type=1`` 专辑区,``item.type=2``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
(web 播放器通道,需 md5 签名;实测 2026-10 HTTP 200)
兜底:www.joox.com/<region>/album/<id> SSR ``__NEXT_DATA__.props.pageProps.albumData``
⚠ 该兜底依赖 X-Forwarded-For 伪装的东南亚 IP;本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此该路径在当前网络环境下不可用,仅在 openjoox2 失败时尝试,失败即如实抛出。
链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
路径里的 album id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径,
故路径写法不影响 secret 校验。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_album_link,
joox_artists,
joox_headers,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
to_int,
)
async def fetch_album_tracks(album_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 专辑曲目(含专辑元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/album/{album_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")), album_id)
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), album_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": joox_artists(body.get("artist_list")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def fetch_album_page_data(album_id: str) -> dict[str, Any]:
"""SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分)。"""
page = await fetch_joox_page(joox_album_link(album_id))
next_data = extract_joox_next_data(page["body"])
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
album_data = page_props.get("albumData")
if isinstance(album_data, dict) and normalize_joox_id(album_data.get("id")):
return album_data
nested = ((page_props.get("content") or {}).get("page") or {}).get("albumData")
if isinstance(nested, dict) and normalize_joox_id(nested.get("id")):
return nested
raise RuntimeError("joox 专辑页数据不存在")
async def get_album_detail_from_page(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径)。"""
album_id = normalize_joox_id(album_id)
if not album_id:
raise RuntimeError("joox 专辑 id 无效")
album_data = await fetch_album_page_data(album_id)
track_list = album_data.get("trackList")
items = track_list.get("items") if isinstance(track_list, dict) else None
items = items if isinstance(items, list) else []
page_start = max(0, (page - 1) * limit)
page_items = items[page_start : page_start + limit]
cover = str(first_non_empty(pick_joox_image(album_data.get("images")), album_data.get("imgSrc")))
songs = [
song
for song in (
joox_song_from_track(item, str(album_data.get("title") or ""), cover, album_id) for item in page_items
)
if song
]
if not songs:
raise RuntimeError("joox 专辑无歌曲")
total = first_non_zero(
track_list.get("total_count") if isinstance(track_list, dict) else None,
track_list.get("list_count") if isinstance(track_list, dict) else None,
len(items),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(album_data.get("title"), album_id)),
"img": cover or None,
"desc": str(first_non_empty(album_data.get("description"), album_data.get("publishDate"))),
"author": joox_artists(album_data.get("artistList")),
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(openjoox2 优先,SSR 页兜底)。"""
normalized = normalize_joox_id(album_id)
if not normalized:
raise RuntimeError("joox 专辑 id 无效")
try:
detail = await fetch_album_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走页面兜底
pass
return await get_album_detail_from_page(normalized, page, limit)
async def get_album_songs(album_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""专辑曲目(上游 GetAlbumSongs:仅曲目列表)。"""
detail = await get_album_detail(album_id, page, limit)
return detail["list"]
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
albums: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict) or to_int(section.get("section_type")) != 1:
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or to_int(item.get("type")) != 2:
continue
raw_album = item.get("album")
album = raw_album if isinstance(raw_album, dict) else {}
album_id = normalize_joox_id(album.get("id"))
if not album_id or album_id in seen:
continue
seen.add(album_id)
publish_date = str(album.get("publish_date") or "").strip()
albums.append(
{
"id": album_id,
"name": str(album.get("name") or ""),
"cover": pick_joox_image(album.get("images")) or None,
"creator": joox_artists(album.get("artist_list")),
"description": publish_date,
"publishDate": publish_date,
"trackCount": 0,
"link": joox_album_link(album_id),
"source": "joox",
}
)
return {"list": albums[:limit], "total": len(albums), "page": page, "limit": limit, "source": "joox"}
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/album/([^/?#]+)",
r"h_activity_id=([^&]+)",
r"albumid=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 10 and "/" not in stripped:
return await get_album_detail(stripped)
raise RuntimeError("invalid joox album link")
+92
View File
@@ -0,0 +1,92 @@
"""JOOX 歌词(对应 sdk/joox/lyric.ts,上游 joox/lyric.go 移植)。
主通道:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
响应形如 ``MusicJsonCallback({"code":0,"lyric":"<base64>"})``,lyric 为 base64 UTF-8 歌词。
实测(2026-10):该旧接口仍可用(同域的 web_get_songinfo 已 404,但 web_lyric 未下线)。
兜底:openjoox2 /track 详情的 ``lrc_content``(同为 base64)。
解码:base64 → UTF-8 → HTML 实体(&amp; 等)→ ``{ lyric, tlyric: "" }``。
QRC(``qrc_content``,XML 格式)上游与 TS 均未解析,此处同样不解析,避免引入未验证逻辑。
"""
from __future__ import annotations
import json
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import build_track_url
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_lyric,
encode_uri_component,
first_non_empty,
joox_headers,
normalize_joox_id,
)
LEGACY_LYRIC_URL = "https://api.joox.com/web-fcgi-bin/web_lyric"
def parse_lyric_payload(body: Any) -> dict[str, Any] | None:
"""解析 MusicJsonCallback(...) / 裸 JSON 包裹。"""
if isinstance(body, dict):
return body
text = str(body if body is not None else "")
index = text.find("MusicJsonCallback(")
if index >= 0:
text = text[index + len("MusicJsonCallback(") :]
text = text.rstrip()
if text.endswith(";"):
text = text[:-1].rstrip()
if text.endswith(")"):
text = text[:-1].rstrip()
try:
parsed = json.loads(text)
except ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def parse_legacy_lyric(status_code: int, body: Any) -> str:
"""上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本。"""
if status_code != 200:
raise RuntimeError(f"joox 歌词获取失败(HTTP {status_code})")
payload = parse_lyric_payload(body)
if not payload:
raise RuntimeError("joox 歌词解析失败")
raw = str(first_non_empty(payload.get("lyric"), ""))
if int(payload.get("code") or 0) != 0 or not raw:
raise RuntimeError("joox 歌词不存在或为空")
return decode_joox_lyric(raw)
async def get_legacy_lyric(song_id: str) -> str:
"""上游 GetLyrics 通道(仅旧接口,测试/内部用)。"""
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return parse_legacy_lyric(res.status_code, res.body)
async def get_lyric(song_info: dict[str, Any], *_rest: Any) -> dict[str, str]:
"""歌词:旧接口优先,失败回退 openjoox2 详情的 lrc_content。"""
song_id = normalize_joox_id(song_info.get("songmid") or song_info.get("id") or song_info.get("songid") or "")
if not song_id:
raise RuntimeError("joox 歌曲 id 无效")
# 1) 上游 web_lyric 通道
legacy_err: Exception | None = None
try:
url = f"{LEGACY_LYRIC_URL}?musicid={encode_uri_component(song_id)}&country=sg&lang=zh_cn"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
return {"lyric": parse_legacy_lyric(res.status_code, res.body), "tlyric": ""}
except Exception as err: # noqa: BLE001 - 降级链:任一失败都转 openjoox2 兜底
legacy_err = err
# 2) 兜底:openjoox2 详情 lrc_content(base64)
res = await http_fetch(build_track_url(song_id), headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body if isinstance(res.body, dict) else {}
raw = str(first_non_empty(body.get("lrc_content"), ""))
if res.status_code != 200 or not raw:
raise legacy_err or RuntimeError("joox 歌词不存在或为空")
return {"lyric": decode_joox_lyric(raw), "tlyric": ""}
+103
View File
@@ -0,0 +1,103 @@
"""JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)。
对应 sdk/joox/musicInfo.ts。
端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
* 参数按**插入序**参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
* 签名串与真正发出的参数来自同一次序列化(同一份 params dict + 同一 encoder),不做二次拼接。
* 实测(2026-10):不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};
按上式 → 200,返回 play_url_list / refrain_url / lrc_content / qrc_content / vip_flag 等。
上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10 前),本模块为其替代。
``fs=1`` 请求完整曲目(游客仍可能拿到 preview/refrain),``im`` 为 mobile 标记。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
cookie_value,
encode_uri_component,
joox_headers,
joox_login_cookie,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
)
JOOX_OPENJOOX2 = "https://cache.api.joox.com/openjoox2/v1"
def build_track_url(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> str:
"""构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk)。"""
track_id = str(normalize_joox_id(raw_id)).replace("/", "_")
params: dict[str, Any] = {
"country": country or "hk",
"lang": normalize_joox_lang(lang or "zh_TW"),
"lyric": 1,
"fs": 1 if full_song else 0,
"im": 1 if is_mobile else 0,
}
# 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
login_cookie = joox_login_cookie()
uid = cookie_value(login_cookie, "wmid") if login_cookie else ""
usk = cookie_value(login_cookie, "session_key") if login_cookie else ""
if uid:
params["uid"] = uid
if usk:
params["usk"] = usk
# 签名时包含 id(与 web 播放器一致),发布 query 时 id 走路径、不进 query
secret = joox_sign({**params, "id": track_id})
query = "&".join(f"{key}={encode_uri_component(value)}" for key, value in {**params, "secret": secret}.items())
return f"{JOOX_OPENJOOX2}/track/{track_id}?{query}"
async def get_track_detail(
raw_id: Any,
country: str = "",
lang: str = "",
full_song: bool = False,
is_mobile: bool = False,
) -> dict[str, Any]:
"""歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现)。"""
track_id = normalize_joox_id(raw_id)
if not track_id:
raise RuntimeError("joox 歌曲 id 无效")
url = build_track_url(track_id.replace("/", "_"), country, lang, full_song, is_mobile)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
msg = ""
if isinstance(body, dict):
msg = str(body.get("msg") or body.get("message") or "")
raise RuntimeError(f"joox 歌曲详情获取失败:{msg or f'HTTP {res.status_code}'}")
return body
def pick_track_url(detail: Any) -> str:
"""从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 &amp; 实体。"""
if not isinstance(detail, dict):
return ""
def clean(value: Any) -> str:
return str(value if value is not None else "").replace("&amp;", "&")
urls = detail.get("play_url_list")
for url in urls if isinstance(urls, list) else []:
text = clean(url)
if text.startswith("http"):
return text
refrain = clean(detail.get("refrain_url"))
return refrain if refrain.startswith("http") else ""
+57
View File
@@ -0,0 +1,57 @@
"""JOOX 搜索(对应 sdk/joox/musicSearch.ts,上游 joox/song.go Search 移植)。
端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
返回 ``section_list``(0 歌曲 / 1 专辑 / 2 歌手 / 3 歌单 / 5 最佳结果 / 7 用户),
歌曲在 ``item_list[].song[].song_info``。
该接口**无分页参数**(上游也未分页),``page`` 仅为统一契约保留;``allPage`` 恒为 1,
``total`` 是去重后的本次结果条数(不是上游分页总数)。
实测(2026-10):中文/英文关键词均 HTTP 200。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.song import joox_song_from_search
from app.sdk.platforms.joox.util import JOOX_TIMEOUT, encode_uri_component, joox_headers
SEARCH_URL = "https://cache.api.joox.com/openjoox/v3/search"
def build_search_url(keyword: str) -> str:
return f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(keyword or '')}"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""搜索歌曲。``page`` 不参与请求(上游无分页),保留以对齐契约。"""
url = build_search_url(str(keyword or ""))
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "allPage": 0, "total": 0, "limit": limit, "source": "joox"}
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict):
continue
for entry in item.get("song") or []:
info = entry.get("song_info") if isinstance(entry, dict) else None
song = joox_song_from_search(info)
if not song or song["songmid"] in seen:
continue
seen.add(song["songmid"])
songs.append(song)
return {
"list": songs[:limit],
"allPage": 1,
"total": len(songs),
"limit": limit,
"source": "joox",
}
+43
View File
@@ -0,0 +1,43 @@
"""JOOX 链接解析(对应 sdk/joox/parse.ts)。
歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.get_album_detail
歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.get_playlist_detail
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.joox.album import parse_album as _parse_album
from app.sdk.platforms.joox.music_info import get_track_detail, pick_track_url
from app.sdk.platforms.joox.playlist import parse_playlist as _parse_playlist
from app.sdk.platforms.joox.song import joox_song_from_detail
from app.sdk.platforms.joox.util import normalize_joox_id
_SINGLE_RE = re.compile(r"joox\.com/.*/single/([^/?#]+)")
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则)。"""
raw = str(link or "").strip()
match = _SINGLE_RE.search(raw)
if match:
song_id = normalize_joox_id(match.group(1))
elif len(raw) > 10 and "/" not in raw:
song_id = normalize_joox_id(raw)
else:
raise RuntimeError("invalid joox link")
detail = await get_track_detail(song_id)
return joox_song_from_detail(detail, pick_track_url(detail))
async def parse_album(link: str) -> dict[str, Any]:
"""专辑链接 → 专辑详情(上游 ParseAlbum)。"""
return await _parse_album(link)
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接 → 歌单详情(上游 ParsePlaylist)。"""
return await _parse_playlist(link)
+420
View File
@@ -0,0 +1,420 @@
"""JOOX 歌单(对应 sdk/joox/playlist.ts,上游 joox/playlist.go 移植)。
两条路:OpenJOOX 接口 + 网页数据兜底。
搜索:GET https://cache.api.joox.com/openjoox/v3/search(``item.type=1`` → ``editor_playlist``)
曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret=
(上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR ``__NEXT_DATA__`` 的
props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
分类:www.joox.com/sg/playlist(302 → /<region> 首页)``__NEXT_DATA__.props.pageProps.mlList``
⚠ 分类与页面兜底都依赖 X-Forwarded-For 伪装的东南亚 IP。本机出口 IP 非东南亚时实测被拦截
(页面只回「Sorry, JOOX is not available in this location」,无 __NEXT_DATA__),
因此 get_categories / get_category_playlists 在当前网络环境下会如实抛错,不返回假数据。
链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID。
路径里的歌单 id 与 TS 一致原样插入(含 ``+`` ``=``);签名只覆盖 query 参数、不含路径。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.joox.music_info import JOOX_OPENJOOX2
from app.sdk.platforms.joox.music_search import SEARCH_URL
from app.sdk.platforms.joox.song import joox_song_from_track
from app.sdk.platforms.joox.util import (
JOOX_TIMEOUT,
decode_joox_base64_text,
encode_uri_component,
extract_joox_next_data,
fetch_joox_page,
first_non_empty,
first_non_zero,
joox_headers,
joox_playlist_link,
joox_playlist_links,
joox_sign,
normalize_joox_id,
normalize_joox_lang,
pick_joox_image,
)
CATEGORY_PAGE = "https://www.joox.com/sg/playlist"
def map_playlist_summary(item: Any) -> dict[str, Any] | None:
if not isinstance(item, dict):
return None
playlist_id = normalize_joox_id(item.get("id"))
if not playlist_id:
return None
return {
"id": playlist_id,
"name": str(item.get("name") or ""),
"cover": pick_joox_image(item.get("images")) or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(playlist_id),
"source": "joox",
}
async def fetch_playlist_tracks(playlist_id: str, page: int, limit: int) -> dict[str, Any] | None:
"""openjoox2 歌单曲目(含歌单元信息)。"""
country = "hk"
lang = normalize_joox_lang("zh_hk")
index = max(0, (page - 1) * limit)
params: dict[str, Any] = {"country": country, "lang": lang, "index": index, "num": limit}
url = (
f"{JOOX_OPENJOOX2}/playlist/{playlist_id}/tracks?"
f"country={country}&lang={lang}&index={index}&num={limit}&secret={joox_sign(params)}"
)
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or body.get("code"):
return None
tracks = body.get("tracks")
items = tracks.get("items") if isinstance(tracks, dict) else None
songs = [
song
for song in (
joox_song_from_track(item, str(body.get("name") or ""), pick_joox_image(body.get("images")))
for item in (items or [])
)
if song
]
if not songs:
return None
total = first_non_zero(
tracks.get("total_count") if isinstance(tracks, dict) else None,
tracks.get("list_count") if isinstance(tracks, dict) else None,
body.get("track_count"),
len(songs),
)
return {
"source": "joox",
"info": {
"name": str(first_non_empty(body.get("name"), playlist_id)),
"img": pick_joox_image(body.get("images")) or None,
"desc": str(first_non_empty(body.get("description"), body.get("publish_date"))),
"author": "",
},
"list": songs,
"page": page,
"limit": limit,
"total": total,
}
def _track_items(tracks: Any) -> list[Any]:
if not isinstance(tracks, dict):
return []
items = tracks.get("items")
return items if isinstance(items, list) else []
def _track_total(*candidates: Any) -> Any:
return first_non_zero(*candidates)
def _nested_name(detail: dict[str, Any], key: str) -> Any:
"""TS 里 ``detail?.user?.name`` 这类嵌套取值的兑底链(name → userName → nickName → nick)。"""
node = detail.get(key)
if not isinstance(node, dict):
return None
return first_non_empty(node.get("name"), node.get("userName"), node.get("nickName"), node.get("nick"))
def parse_playlist_page_data(next_data: Any, page_url: str) -> dict[str, Any] | None:
"""网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植)。"""
page_props = ((next_data or {}).get("props") or {}).get("pageProps") or {}
detail: dict[str, Any] = page_props.get("playlistDetailList") or {}
tracks = _track_items((page_props.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((page_props.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
nested = (page_props.get("content") or {}).get("page") or {}
detail = nested.get("playlistDetailList") or {}
tracks = _track_items((nested.get("allPlaylistTracks") or {}).get("tracks"))
track_count = _track_total(
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("total_count"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("totalCount"),
((nested.get("allPlaylistTracks") or {}).get("tracks") or {}).get("list_count"),
track_count,
)
if not tracks:
tracks = detail.get("trackList") or []
track_count = _track_total(
track_count,
detail.get("trackCount"),
detail.get("totalCount"),
detail.get("total_count"),
detail.get("list_count"),
)
if not tracks:
return None
playlist_id = normalize_joox_id(first_non_empty(detail.get("id"), detail.get("listId"), detail.get("playlistId")))
name = str(first_non_empty(detail.get("name"), detail.get("title"), detail.get("playlistName"), playlist_id))
cover = str(first_non_empty(detail.get("imgSrc"), detail.get("cover"), detail.get("image"), detail.get("pic")))
creator = first_non_empty(
detail.get("creator"),
detail.get("creatorName"),
detail.get("userName"),
detail.get("nickName"),
detail.get("ownerName"),
detail.get("author"),
detail.get("authorName"),
_nested_name(detail, "user"),
_nested_name(detail, "owner"),
_nested_name(detail, "creatorInfo"),
)
description = first_non_empty(detail.get("description"), detail.get("intro"), detail.get("desc"))
return {
"id": playlist_id,
"name": name,
"cover": cover,
"creator": creator,
"description": description,
"trackCount": track_count,
"tracks": tracks,
"pageUrl": page_url,
}
async def get_playlist_detail_from_page(raw_id: str) -> dict[str, Any]:
"""网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径)。"""
playlist_id = normalize_joox_id(raw_id)
if not playlist_id:
raise RuntimeError("joox 歌单 id 无效")
last_err: Exception | None = None
for page_url in joox_playlist_links(playlist_id):
try:
page = await fetch_joox_page(page_url)
parsed = parse_playlist_page_data(extract_joox_next_data(page["body"]), page_url)
if not parsed:
raise RuntimeError("joox 歌单页数据不存在")
page_id = normalize_joox_id(parsed["id"]) or playlist_id
cover = str(parsed["cover"] or "")
songs = [
song
for song in (joox_song_from_track(item, parsed["name"], cover) for item in parsed["tracks"])
if song
]
if not songs:
raise RuntimeError("joox 歌单无歌曲")
return {
"source": "joox",
"info": {
"name": str(parsed["name"] or page_id),
"img": cover or None,
"desc": str(parsed["description"] or ""),
"author": str(parsed["creator"] or ""),
},
"list": songs,
"page": 1,
"limit": len(songs),
"total": first_non_zero(parsed["trackCount"], len(songs)),
}
except Exception as err: # noqa: BLE001 - 逐个区域尝试,全部失败才抛最后一个错误
last_err = err
raise last_err or RuntimeError("joox 歌单页数据不存在")
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留)。"""
normalized = normalize_joox_id(playlist_id)
if not normalized:
raise RuntimeError("joox 歌单 id 无效")
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail
except Exception: # noqa: BLE001 - 降级链:通道不可用时走网页兜底
pass
return await get_playlist_detail_from_page(normalized)
async def get_playlist_songs(playlist_id: str, page: int = 1, limit: int = 50) -> list[dict[str, Any]]:
"""歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底)。"""
normalized = normalize_joox_id(playlist_id)
try:
detail = await fetch_playlist_tracks(normalized, page, limit)
if detail:
return detail["list"]
except Exception: # noqa: BLE001 - 走网页兜底
pass
from_page = await get_playlist_detail_from_page(normalized)
return from_page["list"]
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(上游 SearchPlaylist)。"""
url = f"{SEARCH_URL}?country=sg&lang=zh_cn&keyword={encode_uri_component(str(keyword or ''))}"
res = await http_fetch(url, headers=joox_headers(), timeout=JOOX_TIMEOUT)
body = res.body
if res.status_code != 200 or not isinstance(body, dict) or not body.get("section_list"):
return {"list": [], "total": 0, "page": page, "limit": limit, "source": "joox"}
playlists: list[dict[str, Any]] = []
seen: set[str] = set()
for section in body["section_list"]:
if not isinstance(section, dict):
continue
for item in section.get("item_list") or []:
if not isinstance(item, dict) or _as_int(item.get("type")) != 1:
continue
summary = map_playlist_summary(item.get("editor_playlist"))
if not summary or summary["id"] in seen:
continue
seen.add(summary["id"])
playlists.append(summary)
return {"list": playlists[:limit], "total": len(playlists), "page": page, "limit": limit, "source": "joox"}
async def fetch_playlist_categories_page() -> dict[str, Any]:
"""歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage)。"""
page = await fetch_joox_page(CATEGORY_PAGE)
next_data = extract_joox_next_data(page["body"])
ml_list = ((next_data or {}).get("props") or {}).get("pageProps", {})
ml_list = (ml_list or {}).get("mlList")
if not isinstance(ml_list, dict) or not ml_list.get("category"):
raise RuntimeError("joox 歌单分类不存在")
return ml_list
def category_playlist_id(value: Any) -> str:
"""分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一)。"""
if value is None:
return ""
if isinstance(value, (int, float)) and not isinstance(value, bool):
return str(int(value))
return normalize_joox_id(value)
def _categories(ml_list: dict[str, Any]) -> list[Any]:
categories = ml_list.get("category")
return categories if isinstance(categories, list) else []
async def get_categories() -> dict[str, Any]:
"""分类列表(上游 GetPlaylistCategories;首个为「全部」)。"""
data = await fetch_playlist_categories_page()
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部", "hot": False}]
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
entries = category.get("itemlist")
entries = entries if isinstance(entries, list) else []
if not name or not entries:
continue
items.append({"id": str(index), "name": name, "group": "JOOX", "hot": False, "count": len(entries)})
if len(items) == 1:
raise RuntimeError("joox 歌单分类不存在")
return {"source": "joox", "list": items}
async def get_category_playlists(category_id: str = "", page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类下歌单(上游 GetCategoryPlaylists)。"""
data = await fetch_playlist_categories_page()
cid = str(category_id or "").strip()
items: list[Any] = []
category_name = "全部"
if not cid:
seen: set[str] = set()
for category in _categories(data):
if not isinstance(category, dict):
continue
for item in category.get("itemlist") or []:
entry_id = category_playlist_id(item.get("id") if isinstance(item, dict) else None)
if not entry_id or entry_id in seen:
continue
seen.add(entry_id)
items.append(item)
else:
for index, category in enumerate(_categories(data)):
if not isinstance(category, dict):
continue
name = decode_joox_base64_text(category.get("title"))
if str(index) == cid or (name and name.lower() == cid.lower()):
entries = category.get("itemlist")
items = entries if isinstance(entries, list) else []
category_name = name
break
if not items:
raise RuntimeError("joox 歌单分类不存在")
start = max(0, (page - 1) * limit)
if start >= len(items):
raise RuntimeError("joox 分类下无歌单")
page_items = items[start : start + limit]
playlists: list[dict[str, Any]] = []
for item in page_items:
if not isinstance(item, dict):
continue
entry_id = category_playlist_id(item.get("id"))
name = decode_joox_base64_text(item.get("title"))
if not entry_id or not name:
continue
playlists.append(
{
"id": entry_id,
"name": name,
"cover": str(item.get("picurl") or "").replace("%d", "300") or None,
"trackCount": 0,
"creator": "",
"description": "",
"link": joox_playlist_link(entry_id),
"categoryId": cid,
"categoryName": category_name,
"source": "joox",
}
)
if not playlists:
raise RuntimeError("joox 分类下无歌单")
return {"list": playlists, "total": len(items), "page": page, "limit": limit, "source": "joox"}
async def parse_playlist(link: str) -> dict[str, Any]:
"""歌单链接解析(上游 ParsePlaylist)。"""
raw = str(link or "")
for pattern in (
r"joox\.com/.*/playlist/([^/?#]+)",
r"(?:playlistid|playlist_id|id)=([^&]+)",
):
match = re.search(pattern, raw)
if match:
return await get_playlist_detail(match.group(1))
stripped = raw.strip()
if len(stripped) > 8 and "/" not in stripped:
return await get_playlist_detail(stripped)
raise RuntimeError("invalid joox playlist link")
def _as_int(value: Any) -> int | None:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
+96
View File
@@ -0,0 +1,96 @@
"""JOOX 歌曲形状映射(对应 sdk/joox/song.ts)。
统一 Song 形状(见 tmp/port-spec.md);兼容来源:openjoox2 track/album/playlist 条目、
openjoox v3 search 的 song_info、以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,
上游 Go 也两套都兜底)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.joox.util import (
first_non_empty,
first_non_zero,
joox_artists,
normalize_joox_id,
pick_joox_image,
to_int,
)
def joox_song_from_track(
item: Any,
fallback_album: str = "",
fallback_cover: str = "",
fallback_album_id: str = "",
) -> dict[str, Any] | None:
"""详情/列表 track 条目 → 统一 Song 形状。"""
if not isinstance(item, dict):
item = {}
songmid = normalize_joox_id(item.get("id"))
if not songmid:
return None
artists = item.get("artist_list")
if not (isinstance(artists, list) and artists):
artists = item.get("artistList")
album_id = normalize_joox_id(first_non_empty(item.get("album_id"), item.get("albumId"), fallback_album_id))
return {
"songmid": songmid,
"name": str(first_non_empty(item.get("name"), item.get("title"), "Unknown")),
"singer": str(first_non_empty(joox_artists(artists), "Unknown")),
"albumId": str(album_id or ""),
"albumName": str(first_non_empty(item.get("album_name"), item.get("albumName"), fallback_album)),
"interval": format_play_time(
first_non_zero(item.get("play_duration"), item.get("playDuration"), item.get("duration"))
),
"img": str(
first_non_empty(
pick_joox_image(item.get("images")),
item.get("imgSrc"),
item.get("cover"),
item.get("image"),
item.get("pic"),
fallback_cover,
)
)
or None,
"lrc": None,
"vip": to_int(item.get("vip_flag") if item.get("vip_flag") is not None else 0) == 1,
"types": [],
"_types": {},
"source": "joox",
}
def joox_song_from_search(info: Any) -> dict[str, Any] | None:
"""openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状。"""
return joox_song_from_track(info)
def joox_song_from_detail(detail: Any, play_url: str = "") -> dict[str, Any]:
"""详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状。"""
if not isinstance(detail, dict):
detail = {}
song = joox_song_from_track(detail)
if song is None:
song = {
"songmid": normalize_joox_id(detail.get("id")),
"name": str(detail.get("name") or ""),
"singer": str(joox_artists(detail.get("artist_list"))),
"albumId": str(normalize_joox_id(detail.get("album_id"))),
"albumName": str(detail.get("album_name") or ""),
"interval": format_play_time(first_non_zero(detail.get("play_duration"))),
"img": str(pick_joox_image(detail.get("images"))) or None,
"lrc": None,
"types": [],
"_types": {},
"source": "joox",
}
if play_url:
song["url"] = play_url
song["vip"] = to_int(detail.get("vip_flag") if detail.get("vip_flag") is not None else 0) == 1
return song
+267
View File
@@ -0,0 +1,267 @@
"""JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取(对应 sdk/joox/util.ts)。
背景(与 TS 注释一致,2026-10 实测):
* JOOX 是东南亚服务,www.joox.com 会按来源 IP 返回「Sorry, JOOX is not available in this
location」;上游通过伪造 ``X-Forwarded-For`` 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
* 新版 web 播放器走 ``cache.api.joox.com/openjoox2/v1/*``,**必须**带 secret 签名:
secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent)
密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
实测:不带/带错 secret → 401 {"code":-10005,"msg":"Invalid secret key"};按上式 → 200。
* 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
但 web_lyric 仍可用(歌词首选通道)。
* 登录 cookie 里的 wmid/session_key 在签名参数里作为 uid/usk 参与(VIP 完整曲目依赖它)。
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from typing import Any
from urllib.parse import quote, unquote, urljoin
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
JOOX_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36"
)
"""上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie)。"""
JOOX_DEFAULT_COOKIE = "wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;"
"""上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区拦截)。"""
JOOX_XFF = "36.73.34.109"
"""openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey)。"""
JOOX_SECRET_KEY = "Jo0x@t3Nc3nT"
"""平台统一超时(毫秒),与 TS 各处 timeout: 20000 一致。"""
JOOX_TIMEOUT = 20000
def md5_hex(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def joox_sign(params: dict[str, Any]) -> str:
"""openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)。
**键序即 dict 插入序**(服务端按同序校验),不要重排参数 —— 任何 sorted() 都会破坏签名。
"""
pairs = [f"{key}={encode_uri_component(value)}" for key, value in params.items()]
return md5_hex(JOOX_SECRET_KEY + "&".join(pairs))
def joox_login_cookie() -> str:
"""绑定到 auth_store 的登录 cookie(未登录返回空串,不回退游客 cookie)。"""
cred = get_credentials("joox")
if not cred:
return ""
return str(cred.get("cookie") or "")
def joox_cookie() -> str:
"""请求用 cookie(登录态优先;未登录回退上游游客 cookie)。"""
return joox_login_cookie() or JOOX_DEFAULT_COOKIE
def cookie_value(cookie: str, name: str) -> str:
"""从 cookie 串里取一个字段。"""
match = re.search(rf"(?:^|;\s*){re.escape(name)}=([^;]*)", str(cookie or ""))
return match.group(1).strip() if match else ""
def joox_headers(extra: dict[str, Any] | None = None) -> dict[str, str]:
"""平台公共请求头(含地区伪装与 Cookie)。"""
headers = {
"User-Agent": JOOX_UA,
"Cookie": joox_cookie(),
"X-Forwarded-For": JOOX_XFF,
"Referer": "https://www.joox.com/",
"Accept": "application/json, text/plain, */*",
}
if extra:
headers.update({str(key): str(value) for key, value in extra.items()})
return headers
def normalize_joox_lang(lang: str | None) -> str:
"""语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN)。"""
if lang in ("zh_hk", "zh_mo"):
return "zh_TW"
if lang == "zh_cn":
return "zh_CN"
return lang or "zh_TW"
def normalize_joox_id(raw: Any) -> str:
"""ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +)。"""
normalized = str(raw if raw is not None else "").strip()
if not normalized:
return ""
if "%" in normalized:
try:
normalized = unquote(normalized, errors="strict")
except (UnicodeDecodeError, ValueError):
# 上游 PathUnescape/QueryUnescape 失败时保留原值
pass
normalized = normalized.replace(" ", "+")
return normalized
def joox_song_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/single/{normalize_joox_id(raw_id)}"
def joox_album_link(raw_id: Any) -> str:
return f"https://www.joox.com/hk/album/{normalize_joox_id(raw_id)}"
def joox_regional_playlist_link(region: str, raw_id: Any) -> str:
return f"https://www.joox.com/{region}/playlist/{normalize_joox_id(raw_id)}"
def joox_playlist_link(raw_id: Any) -> str:
return joox_regional_playlist_link("id", raw_id)
def joox_playlist_links(raw_id: Any) -> list[str]:
"""歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用)。"""
return [joox_regional_playlist_link(region, raw_id) for region in ("id", "sg", "hk")]
def pick_joox_image(images: Any) -> str:
"""封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空)。"""
items = images if isinstance(images, list) else []
for width in (300, 1000, 100):
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if to_int(image.get("width")) == width and url:
return url
for image in items:
if not isinstance(image, dict):
continue
url = str(image.get("url") or "").strip()
if url:
return url
return ""
def joox_artists(items: Any) -> str:
"""歌手拼接(上游 joinJooxArtists,分隔符 ' / ')。"""
listing = items if isinstance(items, list) else []
names = [str(entry.get("name") or "").strip() for entry in listing if isinstance(entry, dict)]
return " / ".join(name for name in names if name)
def first_non_empty(*values: Any) -> Any:
for value in values:
if value is None:
continue
if str(value).strip() != "":
return value
return ""
def first_non_zero(*values: Any) -> int | float:
for value in values:
number = to_number(value)
if number is not None and number > 0:
return number
return 0
def to_number(value: Any) -> float | None:
"""对齐 JS Number():有限数值返回 float,其余返回 None。"""
if isinstance(value, bool):
return 1.0 if value else 0.0
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return 0.0
try:
number = float(text)
except ValueError:
return None
else:
return None
if number != number or number in (float("inf"), float("-inf")):
return None
return number
def to_int(value: Any) -> int | None:
number = to_number(value)
return None if number is None else int(number)
def decode_joox_base64_text(value: Any) -> str:
"""base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充)。"""
raw = str(value if value is not None else "").strip()
if not raw:
return ""
normalized = raw.replace("-", "+").replace("_", "/")
padded = normalized + "=" * ((4 - len(normalized) % 4) % 4)
try:
text = base64.b64decode(padded, validate=False).decode("utf-8", errors="replace")
except (ValueError, TypeError):
return raw
# 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
return raw if "\ufffd" in text else text.strip()
def decode_html_entities(text: Any) -> str:
"""HTML 实体解码(歌词文本兜底)。"""
return (
str(text if text is not None else "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&apos;", "'")
.replace("&#0*39;", "'")
.replace("&nbsp;", " ")
)
def decode_joox_lyric(value: Any) -> str:
"""歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底)。"""
return decode_html_entities(decode_joox_base64_text(value))
_NEXT_DATA_RE = re.compile(r'<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)</script>')
def extract_joox_next_data(html: Any) -> Any:
"""从 SSR 页面提取 __NEXT_DATA__ JSON。"""
match = _NEXT_DATA_RE.search(str(html if html is not None else ""))
if not match:
return None
try:
return json.loads(match.group(1))
except ValueError:
return None
async def fetch_joox_page(url: str, max_redirect: int = 3) -> dict[str, Any]:
"""拉取页面并手动跟随 3xx(request.py 不自动跟跳;Go 客户端会自动跟)。
返回 ``{"statusCode", "body", "finalUrl"}``(非字符串 body 视为空串)。
"""
current = url
for _ in range(max_redirect + 1):
res = await http_fetch(current, headers=joox_headers(), timeout=JOOX_TIMEOUT)
location = res.header("location")
if 300 <= res.status_code < 400 and location:
current = urljoin(current, location)
continue
body = res.body if isinstance(res.body, str) else ""
return {"statusCode": res.status_code, "body": body, "finalUrl": current}
raise RuntimeError("joox 页面重定向次数过多")
+65
View File
@@ -0,0 +1,65 @@
"""酷狗音乐(对应 sdk/kg/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析,
顶层挂 get_music_url(取流在 apis/kg.py)、get_lyric、get_pic、get_music_info
(hash 反查歌名/歌手/时长,供取流失败的酷我兜底使用)、get_music_detail_page_url。
"""
from __future__ import annotations
from typing import Any
from app.sdk.apis.kg import get_music_url
from app.sdk.platforms._base import Platform
from app.sdk.platforms.kg import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_info,
music_search,
parse,
pic,
playlist,
singer,
song_list,
)
from app.sdk.platforms.kg.util import get_device
async def _init() -> None:
"""预先固化设备指纹(取流签名依赖 dfid/mid,首次调用时生成并持久化)。"""
get_device()
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
hash_value = song_info.get("hash") or song_info.get("songmid")
album_id = song_info.get("albumId")
return f"https://www.kugou.com/song/#hash={hash_value}&album_id={album_id}"
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""路由兜底调用:传 `{"hash": id, "songmid": id}`,返回含 name/singer/interval 的 dict。"""
return await music_info.get_music_info_for_song_info(song_info)
MODULE = Platform(
id="kg",
name="酷狗音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
singer=singer,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
init=_init,
)
+150
View File
@@ -0,0 +1,150 @@
"""酷狗专辑(对应 sdk/kg/album.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch, encode_uri_component
ALBUM_INFO_URL = (
"http://kmrserviceretry.kugou.com/container/v1/album"
"?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589"
)
ALBUM_SONG_URL = "http://mobiles.kugou.com/api/v3/album/song"
ALBUM_SEARCH_URL = "http://mobilecdn.kugou.com/api/v3/search/album"
_UA_MOBILE = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _album_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
async def _get_album_info(id: Any) -> dict[str, Any]:
result = await create_http_fetch(
ALBUM_INFO_URL,
{
"method": "POST",
"body": {
"appid": 1005,
"clienttime": 1681833686,
"clientver": 11589,
"data": [{"album_id": id}],
"fields": (
"language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,"
"quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,"
"exclusive,is_publish,trans_param,authors,album_tag"
),
"isBuy": 0,
"key": "e6f3306ff7e2afb494e89fbbda0becbf",
"mid": "22945702112173152889429073101964063697",
"show_album_tag": 0,
},
},
)
if not result:
raise RuntimeError("get album info failed.")
info = result[0] if isinstance(result, list) else result
return {
"name": info.get("album_name"),
"image": str(info.get("sizable_cover") or "").replace("{size}", "240"),
"desc": info.get("intro"),
"authorName": info.get("author_name"),
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 200) -> dict[str, Any]:
album_list = await create_http_fetch(
f"{ALBUM_SONG_URL}?version=9108&albumid={album_id}&plat=0&pagesize={limit}"
f"&area_code=0&page={page}&with_res_tag=0"
)
album_list = album_list or {}
if not album_list.get("info"):
raise RuntimeError("Get album list failed.")
songs = await get_music_infos_by_list(album_list.get("info") or [])
info = await _get_album_info(album_id)
return {
"list": songs or [],
"page": page,
"limit": limit,
"total": album_list.get("total"),
"source": "kg",
"info": {
"name": info["name"],
"img": info["image"],
"desc": info["desc"],
"author": info["authorName"],
},
}
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 10))
url = f"{ALBUM_SEARCH_URL}?keyword={encode_uri_component(word)}&format=json&page={page_no}&pagesize={page_size}"
resp = await http_fetch(url, headers={"User-Agent": _UA_MOBILE, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗专辑搜索失败"))
data = body.get("data") or {}
songs: list[dict[str, Any]] = []
for item in data.get("info") or []:
album_id = str(item.get("albumid") or "").strip()
if not album_id or album_id == "0":
continue
songs.append(
{
"id": album_id,
"name": str(item.get("albumname") or "").strip(),
"cover": _album_cover(item.get("imgurl")),
"trackCount": int(item.get("songcount") or 0) or 0,
"creator": str(item.get("singername") or "").strip(),
"description": str(item.get("intro") or "").strip(),
"publishTime": str(item.get("publishtime") or "").strip(),
"link": f"https://www.kugou.com/album/{album_id}.html",
"source": "kg",
"type": "album",
}
)
return {
"source": "kg",
"list": songs,
"page": page_no,
"limit": page_size,
"total": int(data.get("total") or 0) or len(songs),
}
async def get_album_songs(id: Any) -> list[dict[str, Any]]:
album_id = str(id or "").strip()
if not album_id:
raise RuntimeError("酷狗专辑 id 为空")
result = await get_album_detail(album_id, 1, 10000)
return result["list"]
async def parse_album_link(link: str) -> dict[str, Any]:
"""album/single/<id>.html、yy/album/single/<id>.html、album/<id>.html、albumid=<id>。"""
import re
raw = str(link or "")
for pattern in (
r"album/single/(\d+)\.html",
r"yy/album/single/(\d+)\.html",
r"album/(\d+)\.html",
r"albumid=(\d+)",
):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1), 1)
raise RuntimeError("无效的酷狗专辑链接")
+128
View File
@@ -0,0 +1,128 @@
"""酷狗评论(对应 sdk/kg/comment.ts)。
签名:`signatureParams(params)` —— 参数串按键排序拼接后以 android 盐包裹取 MD5。
返回形状是 `{source, comments, total, page, limit, maxPage}`(不是 `list`),
`filterComment` 还要处理 `atlist` 替换与 `pcontent`(楼中楼)两种形态。
"""
from __future__ import annotations
import math
import time
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2, decode_name
from app.sdk.platforms.kg.util import signature_params
_UA = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24"
)
def _comment_params(hash_value: str, page: int, limit: int) -> str:
return (
"dfid=0&mid=16249512204336365674023395779019"
f"&clienttime={int(time.time() * 1000)}&uuid=0&extdata={hash_value}"
"&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&schash={hash_value}&clientver=11409&p={page}&clienttoken=&pagesize={limit}"
"&ver=10&kugouid=0"
)
def _replace_at(raw: str, at_list: list[dict[str, Any]]) -> str:
for item in at_list:
raw = raw.replace(f"[at={item.get('id')}]", f"@{item.get('name')} ")
return raw
def _format_comment(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
content = item.get("content") or ""
if item.get("atlist"):
content = _replace_at(str(content), item["atlist"])
images = [image.get("url") for image in item.get("images") or []] if item.get("images") else []
like = item.get("like") or {}
data = {
"id": item.get("id"),
"text": decode_name(content),
"images": images,
"location": item.get("location"),
"time": item.get("addtime"),
"timeStr": date_format2(item.get("addtime") or 0),
"userName": item.get("user_name"),
"avatar": item.get("user_pic"),
"userId": item.get("user_id"),
"likedCount": like.get("likenum"),
"replyNum": item.get("reply_num"),
"reply": [],
}
if item.get("pcontent"):
# 楼中楼:父评论包一层,原评论挂在 reply 里
result.append(
{
"id": item.get("id"),
"text": decode_name(item["pcontent"]),
"time": None,
"userName": item.get("puser"),
"avatar": None,
"userId": item.get("puser_id"),
"likedCount": None,
"replyNum": None,
"reply": [data],
}
)
else:
result.append(data)
return result
async def _get_comment(hash_value: str, page: int, limit: int, hot: bool) -> dict[str, Any]:
path = "topliked" if hot else "newest"
params = _comment_params(hash_value, page, limit)
resp = await http_fetch(
f"http://m.comment.service.kugou.com/r/v1/rank/{path}?{params}&signature={signature_params(params)}",
headers={"User-Agent": _UA},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
total = body.get("count") or 0
return {
"source": "kg",
"comments": _format_comment(body.get("list") or []),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, False)
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
return await _get_comment(hash_value, page, limit, True)
async def get_reply_comment(
song_info: dict[str, Any], reply_id: Any, page: int = 1, limit: int = 100
) -> dict[str, Any]:
songmid = str(song_info.get("songmid") or "")
children_id = str(song_info.get("audioId") or "").split("_")[0] if len(songmid) == 32 else songmid
resp = await http_fetch(
"http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike"
"&code=fc4be23b4e972707f36b8a828a93ba8a"
f"&p={page}&pagesize={limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1"
f"&appid=1001&childrenid={children_id}&tid={reply_id}",
headers={"User-Agent": "Android712-AndroidPhone-8983-18-0-COMMENT-wifi"},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("err_code") != 0:
raise RuntimeError("获取回复评论失败")
return {"source": "kg", "comments": _format_comment(body.get("list") or [])}
+41
View File
@@ -0,0 +1,41 @@
"""酷狗热搜(对应 sdk/kg/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
HOT_API = (
"http://gateway.kugou.com/api/v3/search/hot_tab"
"?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0"
)
HEADERS = {
"dfid": "1ssiv93oVqMp27cirf2CvoF1",
"mid": "156798703528610303473757548878786007104",
"clienttime": "1584257267",
"x-router": "msearch.kugou.com",
"user-agent": "Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi",
"kg-rc": "1",
}
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
result: list[str] = []
for item in raw_list:
for keyword in item.get("keywords") or []:
result.append(decode_name(keyword.get("keyword")))
return result
async def get_list(retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_API, method="get", headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("errcode") != 0:
raise RuntimeError("获取热搜词失败")
data = body.get("data") or {}
return {"source": "kg", "list": _filter_list(data.get("list") or [])}
+102
View File
@@ -0,0 +1,102 @@
"""酷狗 KRC 歌词解密(对应 sdk/kg/krcDecode.ts)。
密文是 base64 → 去掉 4 字节头 → 与 16 字节固定 key 循环异或 → zlib inflate。
解密后是带 `[start,duration]` 行级标签与 `<offset,duration,0>` 字级标签的 KRC 文本,
另外 `[language:<base64>]` 行里藏着 base64(JSON) 的逐行翻译,需按行号拼回 LRC。
"""
from __future__ import annotations
import base64
import json
import re
import zlib
from typing import Any
from app.sdk.common.utils import decode_name
ENC_KEY = bytes([0x40, 0x47, 0x61, 0x77, 0x5E, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2D, 0xCE, 0xD2, 0x6E, 0x69])
_HEAD_EXP = re.compile(r"^.*\[id:\$\w+\]\n")
_TRANS_EXP = re.compile(r"\[language:([\w=\\/+]+)\]")
_TRANS_LINE_EXP = re.compile(r"\[language:[\w=\\/+]+\]\n")
_LINE_EXP = re.compile(r"\[((\d+),\d+)\].*")
_WORD_TAG_EXP = re.compile(r"<(\d+,\d+),\d+>")
_PLAIN_TAG_EXP = re.compile(r"<\d+,\d+>")
def _xor_decode(data: str) -> str:
if not data:
return ""
buf = bytearray(base64.b64decode(data)[4:])
for i in range(len(buf)):
buf[i] ^= ENC_KEY[i % 16]
return zlib.decompress(bytes(buf)).decode("utf-8", errors="replace")
def _format_time(ms: int) -> str:
"""毫秒 → `mm:ss.ms`(毫秒段与原文一致不补零)。"""
minutes, rem = divmod(ms, 60000)
return f"{minutes:02d}:{rem // 1000:02d}.{ms % 1000}"
def _lrc_line(value: Any) -> str:
"""rlyric/tlyric 的单项是字符串数组(逐字),字符串形态也容忍。"""
if isinstance(value, list):
return "".join(str(item) for item in value)
return "" if value is None else str(value)
def parse_lyric(text: str) -> dict[str, str]:
text = text.replace("\r", "")
if _HEAD_EXP.match(text):
text = _HEAD_EXP.sub("", text, count=1)
rlyric: list[Any] | None = None
tlyric: list[Any] | None = None
trans = _TRANS_EXP.search(text)
if trans:
text = _TRANS_LINE_EXP.sub("", text, count=1)
try:
payload = json.loads(base64.b64decode(trans.group(1)).decode("utf-8", errors="replace"))
except ValueError:
payload = {}
for item in payload.get("content") or []:
if item.get("type") == 0:
rlyric = item.get("lyricContent")
elif item.get("type") == 1:
tlyric = item.get("lyricContent")
index = 0
def repl(match: re.Match[str]) -> str:
nonlocal index
line_match = _LINE_EXP.match(match.group(0))
time_ms = int(line_match.group(2)) if line_match else 0
stamp = _format_time(time_ms)
if rlyric is not None:
rlyric[index] = f"[{stamp}]{_lrc_line(rlyric[index]) if index < len(rlyric) else ''}"
if tlyric is not None:
tlyric[index] = f"[{stamp}]{_lrc_line(tlyric[index]) if index < len(tlyric) else ''}"
index += 1
return match.group(0).replace(match.group(1), stamp, 1)
yrc = re.sub(r"\[((\d+),\d+)\].*", repl, text)
rlyric_text = "\n".join(str(item) for item in rlyric) if rlyric else ""
tlyric_text = "\n".join(str(item) for item in tlyric) if tlyric else ""
yrc = _WORD_TAG_EXP.sub(r"<\1>", yrc)
yrc = decode_name(yrc)
lyric = _PLAIN_TAG_EXP.sub("", yrc)
return {
"lyric": lyric,
"tlyric": decode_name(tlyric_text),
"rlyric": decode_name(rlyric_text),
"yrc": yrc,
}
async def decode_krc(data: str) -> dict[str, str]:
return parse_lyric(_xor_decode(data))
__all__ = ["decode_krc", "parse_lyric"]
+110
View File
@@ -0,0 +1,110 @@
"""酷狗榜单(对应 sdk/kg/leaderboard.ts)。
榜单 id 表是硬编码的静态表(原实现 getBoards 直接返回它,不请求上游);
详情走 `mobilecdnbj.kugou.com/api/v3/rank/song`,返回的 hash 需再反查一次 SongInfo
才能拿到多档音质。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, size_formate
LIST_DETAIL_LIMIT = 100
BOARD_LIST: list[dict[str, str]] = [
{"id": "kgtop500", "name": "TOP500", "bangid": "8888"},
{"id": "kgwlhgb", "name": "网络榜", "bangid": "23784"},
{"id": "kgbsb", "name": "飙升榜", "bangid": "6666"},
{"id": "kgfxb", "name": "分享榜", "bangid": "21101"},
{"id": "kgcyyb", "name": "纯音乐榜", "bangid": "33164"},
{"id": "kggfjqb", "name": "古风榜", "bangid": "33161"},
{"id": "kgyyjqb", "name": "粤语榜", "bangid": "33165"},
{"id": "kgomjqb", "name": "欧美榜", "bangid": "33166"},
{"id": "kgdyrgb", "name": "电音榜", "bangid": "33160"},
{"id": "kgjdrgb", "name": "DJ热歌榜", "bangid": "24971"},
{"id": "kghyxgb", "name": "华语新歌榜", "bangid": "31308"},
]
RANK_API = "http://mobilecdnbj.kugou.com/api/v3/rank/song"
def get_url(page: int, bangid: str, limit: int = LIST_DETAIL_LIMIT) -> str:
return (
f"{RANK_API}?version=9108&ranktype=1&plat=0&pagesize={limit}"
f"&area_code=1&page={page}&rankid={bangid}&with_res_tag=0&show_portrait_mv=1"
)
def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "320filesize", "320hash"),
("flac", "sqfilesize", "sqhash"),
("flac24bit", "filesize_high", "hash_high"),
):
if item.get(size_key, 0) == 0:
continue
size = size_formate(item.get(size_key))
types.append({"type": quality, "size": size, "hash": item.get(hash_key)})
_types[quality] = {"size": size, "hash": item.get(hash_key)}
result.append(
{
"singer": _singer(item.get("authors")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(item.get("remark")),
"albumId": item.get("album_id"),
"songmid": item.get("audio_id"),
"source": "kg",
"interval": format_play_time(item.get("duration")),
"img": None,
"lrc": None,
"hash": item.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _singer(authors: list[dict[str, Any]] | None) -> str:
names = [str(author["author_name"]) for author in authors or [] if author.get("author_name")]
return decode_name("、".join(names))
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "kg"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
resp: Any
try:
resp = await http_fetch(get_url(page, bangid))
except Exception: # noqa: BLE001 - 可降级失败:换一次请求重试
return await get_list(bangid, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
return await get_list(bangid, page, retry_num + 1)
data = body.get("data") or {}
return {
"total": data.get("total"),
"list": _filter_data(data.get("info") or []),
"limit": 100,
"page": page,
"source": "kg",
}
def get_detail_page_url(id: Any) -> str:
value = str(id).replace("kg__", "") if isinstance(id, str) else id
return f"https://www.kugou.com/yy/rank/home/1-{value}.html"
+91
View File
@@ -0,0 +1,91 @@
"""酷狗歌词(对应 sdk/kg/lyric.ts)。
两步:`lyrics.kugou.com/search` 按歌名+hash+时长找候选 → `lyrics.kugou.com/download`
取回 base64 内容。`fmt=krc` 需走 krc_decode 解密,`fmt=lrc` 直接 base64 解码。
"""
from __future__ import annotations
import base64
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg.krc_decode import decode_krc
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def get_intv(interval: Any) -> int:
"""`mm:ss` → 毫秒(原文把秒当毫秒拼给上游,这里保持一致)。"""
if not interval:
return 0
parts = str(interval).split(":")
total = 0
unit = 1
while parts:
part = parts.pop()
try:
total += int(float(part)) * unit
except ValueError:
pass
unit *= 60
return int(total)
async def _search_lyric(name: str, hash_value: str, time: Any, try_num: int = 0) -> dict[str, str] | None:
from app.sdk.platforms.kg.util import encode_uri_component
url = (
"http://lyrics.kugou.com/search?ver=1&man=yes&client=pc"
f"&keyword={encode_uri_component(name)}&hash={hash_value}&timelength={time}&lrctxt=1"
)
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
return await _search_lyric(name, hash_value, time, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
candidates = body.get("candidates") or []
if not candidates:
return None
info = candidates[0]
fmt = "krc" if info.get("krctype") == 1 and info.get("contenttype") != 1 else "lrc"
return {"id": str(info.get("id")), "accessKey": str(info.get("accesskey")), "fmt": fmt}
async def _get_lyric_download(id: str, access_key: str, fmt: str, try_num: int = 0) -> dict[str, str]:
url = f"http://lyrics.kugou.com/download?ver=1&client=pc&id={id}&accesskey={access_key}&fmt={fmt}&charset=utf8"
resp = await http_fetch(url, headers=HEADERS)
if resp.status_code != 200:
if try_num > 5:
raise RuntimeError("歌词获取失败")
# 重试必须回到本方法(搜索接口与下载接口的入参语义不同)
return await _get_lyric_download(id, access_key, fmt, try_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
content = str(body.get("content") or "")
match body.get("fmt"):
case "krc":
return await decode_krc(content)
case "lrc":
return {
"lyric": base64.b64decode(content).decode("utf-8", errors="replace"),
"tlyric": "",
"rlyric": "",
"yrc": "",
}
case _:
raise RuntimeError(f"未知歌词格式: {body.get('fmt')}")
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
name = str(song_info.get("name") or "")
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
time = song_info.get("_interval") or get_intv(song_info.get("interval"))
result = await _search_lyric(name, hash_value, time)
if not result:
raise RuntimeError("Get lyric failed")
return await _get_lyric_download(result["id"], result["accessKey"], result["fmt"])
+138
View File
@@ -0,0 +1,138 @@
"""按 hash 批量反查歌曲信息(对应 sdk/kg/musicInfo.ts)。
多个模块(榜单/歌单/专辑/歌手/解析/路由兜底)都要把 hash 换成完整 SongInfo,
统一走这里:`http://gateway.kugou.com/v3/album_audio/audio`,每批最多 100 个 hash。
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from app.sdk.common.utils import decode_name, format_play_time, size_formate
from app.sdk.platforms.kg.util import create_http_fetch
MUSIC_INFO_URL = "http://gateway.kugou.com/v3/album_audio/audio"
MUSIC_INFO_HEADERS = {
"KG-THash": "13a3164",
"KG-RC": "1",
"KG-Fake": "0",
"KG-RF": "00869891",
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
"x-router": "kmr.service.kugou.com",
}
_FIELDS = "album_info,author_name,audio_info,ori_audio_name,base,songname,classification"
def _filter_music_info_list(raw_list: list[Any]) -> list[dict[str, Any]]:
ids: set[Any] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
if not item:
continue
audio = item.get("audio_info") or {}
if audio.get("audio_id") in ids:
continue
ids.add(audio.get("audio_id"))
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "filesize_320", "hash_320"),
("flac", "filesize_flac", "hash_flac"),
("flac24bit", "filesize_high", "hash_high"),
):
if str(audio.get(size_key, "0")) == "0":
continue
size = size_formate(int(audio.get(size_key) or 0))
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
album = item.get("album_info") or {}
result.append(
{
"singer": decode_name(item.get("author_name")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(album.get("album_name")),
"albumId": album.get("album_id"),
"songmid": audio.get("audio_id"),
"source": "kg",
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
"img": None,
"lrc": None,
"hash": audio.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _build_tasks(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
base = {
"area_code": "1",
"show_privilege": 1,
"show_album_info": "1",
"is_publish": "",
"appid": 1005,
"clientver": 11451,
"mid": "1",
"dfid": "-",
"clienttime": int(time.time() * 1000),
"key": "OIlwieks28dk2k092lksi2UIkp",
"fields": _FIELDS,
}
tasks: list[dict[str, Any]] = []
rest = list(hashs)
while rest:
tasks.append({"data": rest[:100], **base})
if len(rest) < 100:
break
rest = rest[100:]
return tasks
async def get_music_infos(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""按 hash 列表批量取 SongInfo(上游按 100 个一批并发,扁平化后去重)。"""
async def one(task: dict[str, Any]) -> list[Any]:
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": task, "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
return [entry[0] if entry else None for entry in items]
chunks = await asyncio.gather(*(one(task) for task in _build_tasks(hashs)))
return _filter_music_info_list([item for chunk in chunks for item in chunk])
async def get_music_info_raw(hash: str) -> Any:
tasks = _build_tasks([{"hash": hash}])
if not tasks:
return None
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": tasks[0], "headers": MUSIC_INFO_HEADERS})
items = data if isinstance(data, list) else []
flat = [entry[0] if entry else None for entry in items]
return flat[0] if flat else None
async def get_music_info(hash: str) -> dict[str, Any] | None:
infos = await get_music_infos([{"hash": hash}])
return infos[0] if infos else None
async def get_music_infos_by_list(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""上游返回的 {hash} 列表 → 完整 SongInfo 列表。"""
if not items:
return []
return await get_music_infos([{"hash": item.get("hash")} for item in items])
async def get_music_info_for_song_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""顶层 `get_music_info(song_info)` 的实现:hash 优先,退回 songmid。"""
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "").strip()
if not hash_value:
return None
return await get_music_info(hash_value)
+97
View File
@@ -0,0 +1,97 @@
"""酷狗搜索(对应 sdk/kg/musicSearch.ts)。"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time, format_singer_name, size_formate
from app.sdk.platforms.kg.util import encode_uri_component
DEFAULT_LIMIT = 30
_SEARCH_API = "https://songsearch.kugou.com/song_search_v2"
def _filter_data(raw: dict[str, Any]) -> dict[str, Any]:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "FileSize", "FileHash"),
("320k", "HQFileSize", "HQFileHash"),
("flac", "SQFileSize", "SQFileHash"),
("flac24bit", "ResFileSize", "ResFileHash"),
):
if raw.get(size_key, 0) == 0:
continue
size = size_formate(raw.get(size_key))
types.append({"type": quality, "size": size, "hash": raw.get(hash_key)})
_types[quality] = {"size": size, "hash": raw.get(hash_key)}
return {
"singer": decode_name(format_singer_name(raw.get("Singers"), "name")),
"name": decode_name(raw.get("SongName")),
"albumName": decode_name(raw.get("AlbumName")),
"albumId": raw.get("AlbumID"),
"songmid": raw.get("Audioid"),
"source": "kg",
"interval": format_play_time(raw.get("Duration")),
"_interval": raw.get("Duration"),
"img": None,
"lrc": None,
"otherSource": None,
"hash": raw.get("FileHash"),
"types": types,
"_types": _types,
"typeUrl": {},
}
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""同名同 hash 去重;`Grp` 是同一首歌的其它版本,也一并展开。"""
ids: set[str] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
key = f"{item.get('Audioid')}{item.get('FileHash')}"
if key in ids:
continue
ids.add(key)
result.append(_filter_data(item))
for child in item.get("Grp") or []:
child_key = f"{child.get('Audioid')}{child.get('FileHash')}"
if child_key in ids:
continue
ids.add(child_key)
result.append(_filter_data(child))
return result
async def search(keyword: str, page: int = 1, limit: int = DEFAULT_LIMIT, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = (
f"{_SEARCH_API}?keyword={encode_uri_component(keyword)}&page={page}&pagesize={page_size}"
"&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1"
)
try:
resp = await http_fetch(url)
except Exception: # noqa: BLE001 - 可降级失败:原实现同样重试同类请求
return await search(keyword, page, limit, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("error_code") != 0:
return await search(keyword, page, limit, retry_num + 1)
data = body.get("data") or {}
raw_list = data.get("lists")
if raw_list is None:
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(raw_list)
total = data.get("total") or 0
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"limit": page_size,
"total": total,
"source": "kg",
}
+133
View File
@@ -0,0 +1,133 @@
"""酷狗链接解析(对应 sdk/kg/parse.ts)。
歌曲:`https://www.kugou.com/song/#hash=<32位hash>`(含 &album_id= 等尾串)或裸 hash。
元数据取 `m.kugou.com/app/i/getSongInfo.php`(songName/author_name/timeLength/album_img/bitRate),
再并发合并音乐信息里的专辑与多档 hash;直链优先用 playInfo 的 url,仍为空时用 apis/kg
签名通道尽力补一次 —— 失败只返回元数据(与上游付费曲语义一致)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kg import album, playlist
from app.sdk.platforms.kg.music_info import get_music_info
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
HASH_RE = re.compile(r"hash=([a-fA-F0-9]{32})", re.IGNORECASE)
RAW_HASH_RE = re.compile(r"^[a-fA-F0-9]{32}$")
def extract_hash(link: Any) -> str:
"""链接/裸 hash → 32 位小写 hash;都不匹配返回空串。"""
raw = str(link if link is not None else "").strip()
match = HASH_RE.search(raw)
if match:
return match.group(1).lower()
return raw.lower() if RAW_HASH_RE.match(raw) else ""
async def _fetch_song_info_php(hash_value: str) -> dict[str, Any] | None:
resp = await http_fetch(
f"http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash_value}",
method="get",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return None
body = resp.body
if int(body.get("errcode") or 0) != 0 or not body.get("hash"):
return None
return body
async def parse_song(link: str) -> dict[str, Any]:
import asyncio
hash_value = extract_hash(link)
if not hash_value:
raise RuntimeError("无效的酷狗歌曲链接(未找到 32 位 hash)")
raw, info = await asyncio.gather(
_fetch_song_info_php(hash_value), get_music_info(hash_value), return_exceptions=True
)
raw = None if isinstance(raw, BaseException) else raw
info = None if isinstance(info, BaseException) else info
if not raw and not info:
raise RuntimeError("酷狗歌曲不存在或已下架")
raw = raw or {}
info = info or {}
file_name = str(raw.get("fileName") or "")
fallback_name = " - ".join(file_name.split(" - ")[1:]) if " - " in file_name else ""
name = decode_name(str(raw.get("songName") or "").strip() or fallback_name or info.get("name") or "")
singer = decode_name(str(raw.get("author_name") or "").strip() or info.get("singer") or "")
duration = int(raw.get("timeLength") or 0) or 0
song: dict[str, Any] = {
"songmid": str(info.get("songmid") or raw.get("audio_id") or "").strip(),
"hash": str(raw.get("hash") or hash_value).lower(),
"name": name,
"singer": singer,
"albumName": decode_name(str(info.get("albumName") or "").strip()),
"albumId": str(info.get("albumId") or raw.get("albumid") or raw.get("req_albumid") or "").strip(),
"interval": format_play_time(duration) if duration > 0 else (info.get("interval") or "--/--"),
"img": str(raw.get("album_img") or "").replace("{size}", "240") or info.get("img") or None,
"lrc": None,
"otherSource": None,
"types": info.get("types") or [],
"_types": info.get("_types") or {},
"typeUrl": {},
"source": "kg",
"extra": {
"hash": str(raw.get("hash") or hash_value).lower(),
"url": str(raw.get("url") or ""),
"backup_url": raw.get("backup_url") or [],
"bitrate": int(raw.get("bitRate") or 0) or 0,
"link": f"https://www.kugou.com/song/#hash={hash_value}",
},
}
# playInfo 的 url/backup_url 是官方直链(付费曲为空),优先使用
if not song.get("url"):
backup = raw.get("backup_url") or []
raw_url = str(raw.get("url") or "") or str(backup[0] if backup else "")
if raw_url:
song["url"] = raw_url
song["type"] = "320k" if (int(raw.get("bitRate") or 0) or 0) >= 320000 else "128k"
# 仍无直链时(风控/付费)用签名通道尽力补一次
if not song.get("url"):
try:
from app.sdk.apis.kg import get_music_url
hit = await get_music_url(
{
"hash": song["hash"],
"albumId": song["albumId"],
"songmid": song["songmid"],
"_types": song["_types"],
},
"128k",
)
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游付费曲无直链的失败语义一致:元数据仍返回
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)
+62
View File
@@ -0,0 +1,62 @@
"""酷狗封面(对应 sdk/kg/pic.ts)。
走 `media.store.kugou.com/v1/get_res_privilege`,从返回的 `info.image` 里
把 `{size}` 占位替换为上游给的 `imgsize[0]`。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
PIC_API = "http://media.store.kugou.com/v1/get_res_privilege"
HEADERS = {
"KG-RC": "1",
"KG-THash": "expand_search_manager.cpp:852736169:451",
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
}
def _album_audio_id(song_info: dict[str, Any]) -> Any:
"""songmid 为 32 位时是旧形态(audioId_x),要取 `_` 前的段(修复历史存储变更)。"""
songmid = str(song_info.get("songmid") or "")
if len(songmid) == 32:
return str(song_info.get("audioId") or "").split("_")[0]
return song_info.get("songmid")
async def get_pic(song_info: dict[str, Any]) -> str:
body = {
"appid": 1001,
"area_code": "1",
"behavior": "play",
"clientver": "9020",
"need_hash_offset": 1,
"relate": 1,
"resource": [
{
"album_audio_id": _album_audio_id(song_info),
"album_id": song_info.get("albumId"),
"hash": song_info.get("hash"),
"id": 0,
"name": f"{song_info.get('singer')} - {song_info.get('name')}.mp3",
"type": "audio",
}
],
"token": "",
"userid": 2626431536,
"vip": 1,
}
resp = await http_fetch(PIC_API, method="POST", headers=HEADERS, body=body)
payload = resp.body if isinstance(resp.body, dict) else {}
if payload.get("error_code") != 0:
raise RuntimeError("图片获取失败")
data = payload.get("data") or [{}]
info = (data[0] or {}).get("info") or {}
image = str(info.get("image") or "")
img = image.replace("{size}", str(info["imgsize"][0])) if info.get("imgsize") else image
if not img:
raise RuntimeError("Pic get failed")
return img
+233
View File
@@ -0,0 +1,233 @@
"""酷狗歌单模块(对应 sdk/kg/playlist.ts)—— 统一形状层。
解析链复用 song_list 的能力(special/single、songlist/gcid、share chain、zlist、
global_collection_id 等),本模块只做形状归一:摘要 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.kg import song_list
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _playlist_cover(url: Any) -> str:
return str(url or "").replace("{size}", "240")
def _to_summary(item: dict[str, Any]) -> dict[str, Any] | None:
raw_id = _first_non_empty(item.get("specialid"), item.get("global_specialid"), item.get("id"))
playlist_id = re.sub(r"^id_", "", raw_id)
if not playlist_id:
return None
play_count = _finite(item.get("playcount"))
return {
"id": playlist_id,
"name": _first_non_empty(item.get("specialname"), item.get("name")),
"cover": _playlist_cover(_first_non_empty(item.get("imgurl"), item.get("img"))),
"trackCount": int(item.get("songcount") or item.get("total") or 0) or 0,
"playCount": play_count if play_count is not None else (item.get("play_count") or 0),
"creator": _first_non_empty(item.get("nickname"), item.get("username"), item.get("author")),
"description": _first_non_empty(item.get("intro"), item.get("desc")),
"publishTime": str(item.get("publishtime") or item.get("time") or "").strip(),
"link": f"https://www.kugou.com/yy/special/single/{playlist_id}.html",
"source": "kg",
}
def _finite(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number == number and number not in (float("inf"), float("-inf")) else None
def _normalize_playlist_id(id: Any) -> str:
"""数字 id / id_ 前缀 / 链接 → song_list.get_list_detail 可识别的入参。"""
raw = str(id or "").strip()
if not raw:
return ""
return f"id_{raw}" if re.fullmatch(r"\d+", raw) else raw
def _parse_category_id(category_id: Any) -> tuple[str, str]:
parts = str(category_id or "").strip().split(":")
return str(parts[0] if parts else "").strip(), (str(parts[1]).strip() if len(parts) > 1 else "") or "0"
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kg", "list": [], "page": page, "limit": limit, "total": 0}
res = await song_list.search(word, page, limit)
items = [summary for summary in (_to_summary(item) for item in res.get("list") or []) if summary]
return {
"source": "kg",
"list": items,
"page": page,
"limit": limit,
"total": int(res.get("total") or 0) or len(items),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
request_id = _normalize_playlist_id(playlist_id)
if not request_id:
raise RuntimeError("酷狗歌单 id 为空")
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 50))
res = await song_list.get_list_detail(request_id, page_no)
if not res:
raise RuntimeError("酷狗歌单不存在或解析失败")
songs: list[dict[str, Any]] = res.get("list") or []
total = int(res.get("total") or 0) or len(songs)
if len(songs) > page_size:
start = (page_no - 1) * page_size
songs = songs[start : start + page_size]
info = res.get("info") or {}
return {
"source": "kg",
"info": {
"name": _first_non_empty(info.get("name"), str(playlist_id)),
"img": _playlist_cover(info.get("img")),
"desc": _first_non_empty(info.get("desc")),
"author": _first_non_empty(info.get("author")),
"play_count": info.get("play_count") or "",
},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total,
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
res = await song_list.get_list_detail(_normalize_playlist_id(playlist_id), 1)
return (res or {}).get("list") or []
async def get_categories() -> dict[str, Any]:
resp = await http_fetch(
"http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗歌单分类获取失败"))
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
for group in (body.get("data") or {}).get("info") or []:
group_name = str(group.get("name") or "").strip()
group_id = int(group.get("id") or 0) or 0
if group_id > 0 and group_name:
items.append({"id": f"{group_id}:0", "name": group_name, "group": group_name})
for child in group.get("children") or []:
child_id = int(child.get("id") or 0) or 0
child_name = str(child.get("name") or "").strip()
if not child_id or not child_name:
continue
tag_id = int(child.get("special_tag_id") or 0) or 0
items.append(
{
"id": f"{child_id}:{tag_id}",
"name": child_name,
"group": group_name,
"hot": int(child.get("is_hot") or 0) == 1,
}
)
return {"source": "kg", "list": items}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 20))
category, tag_id = _parse_category_id(category_id)
if not category:
listing = await get_categories()
first = next((item for item in listing["list"] if item.get("id")), None)
if not first:
raise RuntimeError("酷狗歌单分类为空")
category, tag_id = _parse_category_id(first["id"])
api_url = "http://mobilecdnbj.kugou.com/api/v3/tag/specialList?" + "&".join(
["plat=0", f"page={page_no}", f"tagid={tag_id}", f"pagesize={page_size}", "ugc=1", f"id={category}", "sort=2"]
)
resp = await http_fetch(api_url, headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"}, timeout=15000)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
raise RuntimeError(str(body.get("error") or "酷狗分类歌单获取失败"))
items: list[dict[str, Any]] = []
for entry in (body.get("data") or {}).get("info") or []:
summary = _to_summary(entry) or _to_summary({**entry, "specialid": entry.get("global_specialid")})
if not summary:
continue
summary["categoryId"] = f"{category}:{tag_id}"
items.append(summary)
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int((body.get("data") or {}).get("total") or 0) or len(items),
}
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
page_no = max(1, int(page or 1))
page_size = max(1, int(limit or 30))
resp = await http_fetch(
f"http://m.kugou.com/plist/index&json=true&page={page_no}",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else None
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError("酷狗推荐歌单获取失败")
plist = (body.get("plist") or {}).get("list") or {}
items = [summary for summary in (_to_summary(item) for item in plist.get("info") or []) if summary]
if len(items) > page_size:
items = items[:page_size]
return {
"source": "kg",
"list": items,
"page": page_no,
"limit": page_size,
"total": int(plist.get("total") or 0) or len(items),
}
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""special/single/<id>.html 与 songlist/<gcid_xxx>/ 优先,其余交给 song_list 通用解析。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的酷狗歌单链接")
special = re.search(r"special/single/(\d+)\.html", raw)
if special:
return await get_playlist_detail(special.group(1), 1)
gcid = re.search(r"songlist/(gcid_[A-Za-z0-9]+)", raw)
if gcid:
return await get_playlist_detail(gcid.group(1), 1)
return await get_playlist_detail(raw, 1)
+85
View File
@@ -0,0 +1,85 @@
"""酷狗歌手(对应 sdk/kg/singer.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
from app.sdk.platforms.kg.util import create_http_fetch
SINGER_INFO_URL = "http://mobiles.kugou.com/api/v5/singer/info"
SINGER_ALBUM_URL = "http://mobiles.kugou.com/api/v5/singer/album"
SINGER_SONG_URL = "http://mobiles.kugou.com/api/v5/singer/song"
def _check_id(id: Any) -> None:
# kg 源有些歌曲在歌手未被酷狗收录时返回的歌手 id 为 0
if str(id) in ("0", ""):
raise RuntimeError("歌手不存在")
async def get_info(id: str) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_INFO_URL}?singerid={id}")
body = body or {}
if not body:
raise RuntimeError("get singer info faild.")
imgurl = str(body.get("imgurl") or "")
return {
"source": "kg",
"id": body.get("singerid"),
"info": {
"name": body.get("singername"),
"desc": body.get("intro"),
"avatar": imgurl.replace("{size}", "480") if imgurl else "",
"gender": "man" if body.get("grade") == 1 else "woman",
},
"count": {"music": body.get("songcount"), "album": body.get("albumcount")},
}
def _filter_album_list(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"id": item.get("albumid"),
"count": item.get("songcount"),
"info": {
"name": item.get("albumname"),
"author": item.get("singername"),
"img": str(item.get("imgurl") or item.get("img") or "").replace("{size}", "480"),
"desc": item.get("intro"),
},
}
for item in raw
]
async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_ALBUM_URL}?singerid={id}&page={page}&pagesize={limit}")
body = body or {}
if not body.get("info"):
raise RuntimeError("get singer album list faild.")
return {
"source": "kg",
"list": _filter_album_list(body.get("info") or []),
"limit": limit,
"page": page,
"total": body.get("total"),
}
async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
_check_id(id)
body = await create_http_fetch(f"{SINGER_SONG_URL}?singerid={id}&page={page}&pagesize={limit}")
body = body or {}
if not body.get("info"):
raise RuntimeError("get singer song list faild.")
songs = await get_music_infos_by_list(body.get("info") or [])
return {
"source": "kg",
"list": songs,
"limit": limit,
"page": page,
"total": body.get("total"),
}
+844
View File
@@ -0,0 +1,844 @@
"""酷狗歌单广场与歌单解析(对应 sdk/kg/songList.ts,31KB 的最大模块)。
三个入口:
get_list(sort_id, tag_id, page) —— 歌单广场(special/getSpecial + 详情 + 推荐)
get_list_detail(id, page) —— 歌单解析,覆盖 special/single、酷狗码、gcid、
share chain、zlist、global_collection_id、网页链接
search(keyword, page, limit) —— 歌单搜索(msearchretry v3 search/special)
原实现的 `_requestObj*` 取消句柄在 Python 侧无对应物(http_fetch 自身不可取消),
已省略;其余降级链、重试次数、错误文案与原实现保持一致。
"""
from __future__ import annotations
import asyncio
import json
import re
import time
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, size_formate, to_md5
from app.sdk.platforms.kg.util import create_http_fetch, signature_params
LIST_DETAIL_LIMIT = 10000
SORT_LIST: list[dict[str, str]] = [
{"name": "推荐", "id": "5"},
{"name": "最热", "id": "6"},
{"name": "最新", "id": "7"},
{"name": "热藏", "id": "3"},
{"name": "飙升", "id": "8"},
]
_LIST_DATA_EXP = re.compile(r"global\.data = (\[.+\]);")
_LIST_INFO_EXP = re.compile(r'global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};')
_LIST_DETAIL_LINK_EXP = re.compile(r"^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)")
# chain → 分享信息缓存(原实现是 Map,跨请求复用同一进程缓存)
_CACHE: dict[str, Any] = {}
_CURRENT_TAG_INFO: dict[str, Any] = {"id": None, "info": None}
def _handle_signature(id: Any, page: int, limit: int) -> str:
"""H5 签名:业务参数按键排序拼接后以 key 包裹取 MD5(与 signature_params 同构)。"""
params = {
"srcappid": "2919",
"clientver": "20000",
"clienttime": int(time.time() * 1000),
"mid": "0",
"uuid": "0",
"dfid": "-",
"appid": 1058,
"type": 0,
"module": "playlist",
"page": page,
"pagesize": limit,
"specialid": id,
}
key = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt"
sorted_params = "".join(f"{k}={params[k]}" for k in sorted(params))
return to_md5(f"{key}{sorted_params}{key}")
def parse_html_desc(html: str) -> str | None:
prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
index = html.find(prefix)
if index < 0:
return None
after = html[index + len(prefix) :]
end = after.find("</div>")
if end < 0:
return None
return decode_name(after[:end])
def _filter_info_hot_tag(raw: dict[str, Any]) -> list[dict[str, Any]]:
if raw.get("status") != 1:
return []
return [
{"id": tag.get("special_id"), "name": tag.get("special_name"), "source": "kg"}
for tag in (raw.get("data") or {}).values()
]
def _filter_tag_info(raw: dict[str, Any]) -> list[dict[str, Any]]:
return [
{
"name": name,
"list": [
{
"parent_id": tag.get("parent_id"),
"parent_name": tag.get("pname"),
"id": tag.get("id"),
"name": tag.get("name"),
"source": "kg",
}
for tag in (raw[name].get("data") or [])
],
}
for name in raw
]
def _filter_list(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"play_count": item.get("total_play_count") or format_play_count(item.get("play_count")),
"id": "id_" + str(item.get("specialid")),
"author": item.get("nickname"),
"name": item.get("specialname"),
"time": date_format(item.get("publish_time") or item.get("publishtime"), "Y-M-D"),
"img": item.get("img") or item.get("imgurl"),
"total": item.get("songcount"),
"grade": item.get("grade"),
"desc": item.get("intro"),
"source": "kg",
}
for item in raw_data
]
def _create_task_body(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
base = {
"area_code": "1",
"show_privilege": 1,
"show_album_info": "1",
"is_publish": "",
"appid": 1005,
"clientver": 11451,
"mid": "1",
"dfid": "-",
"clienttime": int(time.time() * 1000),
"key": "OIlwieks28dk2k092lksi2UIkp",
"fields": "album_info,author_name,audio_info,ori_audio_name,base,songname",
}
tasks: list[dict[str, Any]] = []
rest = list(hashs)
while rest:
tasks.append({"data": rest[:100], **base})
if len(rest) < 100:
break
rest = rest[100:]
return tasks
async def _create_task(hashs: list[dict[str, Any]]) -> list[list[Any]]:
url = "http://gateway.kugou.com/v2/album_audio/audio"
headers = {
"KG-THash": "13a3164",
"KG-RC": "1",
"KG-Fake": "0",
"KG-RF": "00869891",
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
"x-router": "kmr.service.kugou.com",
}
async def one(task: dict[str, Any]) -> list[Any]:
data = await create_http_fetch(url, {"method": "POST", "body": task, "headers": headers})
items = data if isinstance(data, list) else []
return [entry[0] if entry else None for entry in items]
return await asyncio.gather(*(one(task) for task in _create_task_body(hashs)))
def _de_duplication(datas: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[Any] = set()
result: list[dict[str, Any]] = []
for item in datas:
hash_value = item.get("hash")
if hash_value in seen:
continue
seen.add(hash_value)
result.append(item)
return result
async def _get_music_infos(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""hash 列表 → SongInfo(走 v2 album_audio 批量接口,与 musicInfo.ts 的 v3 不同)。"""
deduped = _de_duplication(items)
if not deduped:
return []
chunks = await _create_task([{"hash": item.get("hash")} for item in deduped])
return _filter_data2([entry for chunk in chunks for entry in chunk])
def _filter_data2(raw_list: list[Any]) -> list[dict[str, Any]]:
ids: set[Any] = set()
result: list[dict[str, Any]] = []
for item in raw_list:
if not item:
continue
audio = item.get("audio_info") or {}
if audio.get("audio_id") in ids:
continue
ids.add(audio.get("audio_id"))
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for quality, size_key, hash_key in (
("128k", "filesize", "hash"),
("320k", "filesize_320", "hash_320"),
("flac", "filesize_flac", "hash_flac"),
("flac24bit", "filesize_high", "hash_high"),
):
if str(audio.get(size_key, "0")) == "0":
continue
size = size_formate(int(audio.get(size_key) or 0))
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
album = item.get("album_info") or {}
result.append(
{
"singer": decode_name(item.get("author_name")),
"name": decode_name(item.get("songname")),
"albumName": decode_name(album.get("album_name")),
"albumId": album.get("album_id"),
"songmid": audio.get("audio_id"),
"source": "kg",
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
"img": None,
"lrc": None,
"hash": audio.get("hash"),
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
async def get_list_detail_by_special_id(id: Any, page: int | None = None, try_num: int = 0) -> dict[str, Any]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_song_list_detail_url(id))
html = resp.body if isinstance(resp.body, str) else ""
list_data = _LIST_DATA_EXP.search(html)
list_info = _LIST_INFO_EXP.search(html)
if not list_data:
return await get_list_detail_by_special_id(id, page, try_num + 1)
songs = await _get_music_infos(json.loads(list_data.group(1)))
name = list_info.group(1) if list_info else None
pic = list_info.group(2) if list_info else None
return {
"list": songs,
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {"name": name, "img": pic, "desc": parse_html_desc(html)},
}
def get_info_url(tag_id: Any = None) -> str:
if tag_id:
return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c={tag_id}"
return "http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&"
def get_song_list_url(sort_id: Any, tag_id: Any, page: int) -> str:
tag = "" if tag_id is None else tag_id
return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t={sort_id}&c={tag}&p={page}"
def get_song_list_detail_url(id: Any) -> str:
return f"http://www2.kugou.kugou.com/yueku/v9/special/single/{id}-5-9999.html"
async def _get_song_list(sort_id: Any, tag_id: Any, page: int, try_num: int = 0) -> list[dict[str, Any]]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_song_list_url(sort_id, tag_id, page))
body = resp.body if isinstance(resp.body, dict) else {}
if not body or body.get("status") != 1:
return await _get_song_list(sort_id, tag_id, page, try_num + 1)
return _filter_list(body.get("special_db") or [])
async def _get_song_list_recommend(try_num: int = 0) -> list[dict[str, Any]]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
"http://everydayrec.service.kugou.com/guess_special_recommend",
method="post",
headers={"User-Agent": "KuGou2012-8275-web_browser_event_handler"},
body={
"appid": 1001,
"clienttime": 1566798337219,
"clientver": 8275,
"key": "f1f93580115bb106680d2375f8032d96",
"mid": "21511157a05844bd085308bc76ef3343",
"platform": "pc",
"userid": "262643156",
"return_min": 6,
"return_max": 15,
},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
return await _get_song_list_recommend(try_num + 1)
return _filter_list((body.get("data") or {}).get("special_list") or [])
async def _get_list_info(tag_id: Any, try_num: int = 0) -> dict[str, Any]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_info_url(tag_id))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
return await _get_list_info(tag_id, try_num + 1)
params = (body.get("data") or {}).get("params") or {}
return {
"limit": params.get("pagesize"),
"page": params.get("p"),
"total": params.get("total"),
"source": "kg",
}
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
"""歌单广场:首屏(无标签、第 1 页、默认排序)额外混入推荐歌单。"""
tasks: list[Any] = [_get_song_list(sort_id, tag_id, page)]
if _CURRENT_TAG_INFO["id"] == tag_id:
tasks.append(asyncio.sleep(0, result=_CURRENT_TAG_INFO["info"]))
else:
async def load_info() -> dict[str, Any]:
info = await _get_list_info(tag_id)
_CURRENT_TAG_INFO["id"] = tag_id
_CURRENT_TAG_INFO["info"] = dict(info)
return info
tasks.append(load_info())
if not tag_id and page == 1 and sort_id == SORT_LIST[0]["id"]:
tasks.append(_get_song_list_recommend())
results = await asyncio.gather(*tasks)
songs: list[dict[str, Any]] = results[0]
info: dict[str, Any] = results[1]
if len(results) > 2 and results[2]:
songs = [*results[2], *songs]
return {"list": songs, **info}
async def get_tags(try_num: int = 0) -> dict[str, Any]:
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(get_info_url())
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("status") != 1:
return await get_tags(try_num + 1)
data = body.get("data") or {}
return {
"hotTag": _filter_info_hot_tag(data.get("hotTag") or {}),
"tags": _filter_tag_info(data.get("tagids") or {}),
"source": "kg",
}
def get_detail_page_url(id: Any) -> str:
value = id
if isinstance(value, str):
if re.search(r"^https?://", value):
return value
value = value.replace("id_", "")
return f"https://www.kugou.com/yy/special/single/{value}.html"
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
from app.sdk.platforms.kg.util import encode_uri_component
url = (
f"http://msearchretry.kugou.com/api/v3/search/special?keyword={encode_uri_component(keyword)}"
f"&page={page}&pagesize={limit}&showtype=10&filter=0&version=7910&sver=2"
)
resp = await http_fetch(url)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
raise RuntimeError("filed")
data = body.get("data") or {}
return {
"list": [
{
"play_count": format_play_count(item.get("playcount")),
"id": "id_" + str(item.get("specialid")),
"author": item.get("nickname"),
"name": item.get("specialname"),
"time": date_format(item.get("publishtime"), "Y-M-D"),
"img": item.get("imgurl"),
"grade": item.get("grade"),
"desc": item.get("intro"),
"total": item.get("songcount"),
"source": "kg",
}
for item in data.get("info") or []
],
"limit": limit,
"total": data.get("total"),
"page": page,
"source": "kg",
}
# ---- 歌单详情解析链 ----
async def _decode_gcid(gcid: str) -> str | None:
params = "dfid=-&appid=1005&mid=0&clientver=20109&clienttime=640612895&uuid=-"
body = {"ret_info": 1, "data": [{"id": gcid, "id_type": 2}]}
result = await create_http_fetch(
f"https://t.kugou.com/v1/songlist/batch_decode?{params}"
f"&signature={signature_params(params, 'android', json.dumps(body, separators=(',', ':')))}",
{
"method": "POST",
"headers": {
"User-Agent": (
"Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36"
),
"Referer": "https://m.kugou.com/",
},
"body": body,
},
)
items = (result or {}).get("list") or []
return items[0].get("global_collection_id") if items else None
async def _get_user_list_detail_2(global_collection_id: str) -> dict[str, Any]:
if len(global_collection_id) > 1000:
raise RuntimeError("get list error")
params = (
"appid=1058&specialid=0&global_specialid="
+ global_collection_id
+ "&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519"
"&mid=1586163242519&uuid=1586163242519&dfid=-"
)
headers = {
"mid": "1586163242519",
"Referer": "https://m3ws.kugou.com/share/index.php",
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
),
"dfid": "-",
"clienttime": "1586163242519",
}
info = await create_http_fetch(
f"https://mobiles.kugou.com/api/v5/special/info_v2?{params}&signature={signature_params(params, 'web')}",
{"headers": headers},
)
info = info or {}
total = info.get("songcount") or 0
tasks: list[Any] = []
page = 0
while total:
limit = 300 if total > 300 else total
total -= limit
page += 1
song_params = (
f"appid=1058&global_specialid={global_collection_id}&specialid=0&plat=0&version=8000"
f"&page={page}&pagesize={limit}&srcappid=2919&clientver=20000"
"&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-"
)
tasks.append(
create_http_fetch(
f"https://mobiles.kugou.com/api/v5/special/song_v2?{song_params}"
f"&signature={signature_params(song_params, 'web')}",
{
"headers": {
"mid": "1586163263991",
"Referer": "https://m3ws.kugou.com/share/index.php",
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
),
"dfid": "-",
"clienttime": "1586163263991",
}
},
)
)
song_rows: list[Any] = []
for chunk in await asyncio.gather(*tasks):
song_rows.extend((chunk or {}).get("info") or [])
songs = await _get_music_infos(song_rows)
img = info.get("imgurl")
return {
"list": songs,
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {
"name": info.get("specialname"),
"img": img.replace("{size}", "240") if img else None,
"desc": info.get("intro"),
"author": info.get("nickname"),
"play_count": format_play_count(info.get("playcount")),
},
}
async def _get_list_info_by_chain(chain: str) -> Any:
if chain in _CACHE:
return _CACHE[chain]
resp = await http_fetch(
f"https://m.kugou.com/share/?chain={chain}&id={chain}",
headers={
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
},
)
html = resp.body if isinstance(resp.body, str) else ""
match = re.search(r"var\sphpParam\s=\s({.+?});", html)
result = json.loads(match.group(1)) if match else None
_CACHE[chain] = result
return result
async def _get_user_list_detail_by_pc_chain(chain: str) -> list[dict[str, Any]]:
resp = await http_fetch(
f"http://www.kugou.com/share/{chain}.html",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
)
},
)
html = resp.body if isinstance(resp.body, str) else ""
match = re.search(r"var\sdataFromSmarty\s=\s(\[.+?\])", html)
result = json.loads(match.group(1)) if match else []
return await _get_music_infos(result)
async def _get_user_list_detail_by_id(id: Any, page: int, limit: int) -> list[dict[str, Any]]:
signature = _handle_signature(id, page, limit)
info = await create_http_fetch(
"https://pubsongscdn.kugou.com/v2/get_other_list_file?"
f"srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist"
f"&page={page}&pagesize={limit}&specialid={id}&signature={signature}",
{
"headers": {
"Referer": "https://m3ws.kugou.com/share/index.php",
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
),
"dfid": "-",
}
},
)
return await _get_music_infos((info or {}).get("info") or [])
async def _get_user_list_detail_4(song_info: dict[str, Any], chain: str, page: int) -> dict[str, Any]:
limit = 100
list_info, songs = await asyncio.gather(
_get_list_info_by_chain(chain),
_get_user_list_detail_by_id(song_info.get("id"), page, limit),
)
list_info = list_info or {}
img = list_info.get("imgurl")
return {
"list": songs or [],
"page": page,
"limit": limit,
"total": len(songs or []),
"source": "kg",
"info": {
"name": list_info.get("specialname"),
"img": img.replace("{size}", "240") if img else None,
"author": list_info.get("nickname"),
},
}
async def _get_user_list_detail_5(chain: str) -> dict[str, Any]:
list_info, songs = await asyncio.gather(_get_list_info_by_chain(chain), _get_user_list_detail_by_pc_chain(chain))
list_info = list_info or {}
img = list_info.get("imgurl")
return {
"list": songs or [],
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs or []),
"source": "kg",
"info": {
"name": list_info.get("specialname"),
"img": img.replace("{size}", "240") if img else None,
"author": list_info.get("nickname"),
},
}
async def _get_user_list_detail_3(chain: str, page: int) -> dict[str, Any]:
song_info = await create_http_fetch(
f"http://m.kugou.com/schain/transfer?pagesize={LIST_DETAIL_LIMIT}&chain={chain}"
f"&su=1&page={page}&n=0.7928855356604456",
{
"headers": {
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
)
}
},
)
song_info = song_info or {}
if not song_info.get("list"):
if song_info.get("global_collection_id"):
return await _get_user_list_detail_2(song_info["global_collection_id"])
try:
return await _get_user_list_detail_4(song_info, chain, page)
except Exception: # noqa: BLE001 - 降级到 PC 分享页
return await _get_user_list_detail_5(chain)
songs = await _get_music_infos(song_info.get("list") or [])
info = song_info.get("info") or {}
return {
"list": songs,
"page": 1,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {"name": info.get("name"), "img": info.get("img"), "author": info.get("username")},
}
async def _get_user_list_detail_by_code(id: str) -> dict[str, Any]:
song_info = await create_http_fetch(
"http://t.kugou.com/command/",
{
"method": "POST",
"headers": {
"KG-RC": 1,
"KG-THash": "network_super_call.cpp:3676261689:379",
"User-Agent": "",
},
"body": {
"appid": 1001,
"clientver": 9020,
"mid": "21511157a05844bd085308bc76ef3343",
"clienttime": 640612895,
"key": "36164c4015e704673c588ee202b9ecb8",
"data": id,
},
},
)
song_info = song_info or {}
info = song_info.get("info") or {}
# type 1 单曲 / 2 歌单 / 3 电台 / 4 酷狗码 / 5 别人的播放队列
if info.get("type") == 2 and not info.get("global_collection_id"):
return await get_list_detail_by_special_id(info.get("id"))
if info.get("global_collection_id"):
return await _get_user_list_detail_2(info["global_collection_id"])
song_list: Any = None
if info.get("userid") is not None:
song_list = await create_http_fetch(
"http://www2.kugou.kugou.com/apps/kucodeAndShare/app/",
{
"method": "POST",
"headers": {
"KG-RC": 1,
"KG-THash": "network_super_call.cpp:3676261689:379",
"User-Agent": "",
},
"body": {
"appid": 1001,
"clientver": 9020,
"mid": "21511157a05844bd085308bc76ef3343",
"clienttime": 640612895,
"key": "36164c4015e704673c588ee202b9ecb8",
"data": {
"id": info.get("id"),
"type": 3,
"userid": info.get("userid"),
"collect_type": 0,
"page": 1,
"pagesize": info.get("count"),
},
},
},
)
rows = song_list or song_info.get("list") or []
songs = await _get_music_infos(rows)
img_size = info.get("img_size")
return {
"list": songs,
"page": 1,
"limit": info.get("count"),
"total": len(songs),
"source": "kg",
"info": {
"name": info.get("name"),
"img": img_size.replace("{size}", "240") if img_size else info.get("img"),
"author": info.get("username"),
},
}
async def _get_user_list_detail_by_link(body: dict[str, Any], link: str) -> dict[str, Any]:
list_info = (body.get("info") or {}).get("0") or {}
total = list_info.get("count") or 0
tasks: list[Any] = []
page = 0
while total:
limit = 90 if total > 90 else total
total -= limit
page += 1
url = re.sub(r"pagesize=\d+", f"pagesize={limit}", link)
url = re.sub(r"page=\d+", f"page={page}", url)
tasks.append(
create_http_fetch(
url,
{
"headers": {
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
),
"Referer": link,
}
},
)
)
rows: list[Any] = []
for chunk in await asyncio.gather(*tasks):
rows.extend(((chunk or {}).get("list") or {}).get("info") or [])
songs = await _get_music_infos(rows)
pic = list_info.get("pic")
return {
"list": songs,
"page": page,
"limit": LIST_DETAIL_LIMIT,
"total": len(songs),
"source": "kg",
"info": {
"name": list_info.get("name"),
"img": pic.replace("{size}", "240") if pic else None,
"author": list_info.get("list_create_username"),
},
}
async def _get_user_list_detail(link: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 3:
raise RuntimeError("link try max num")
if "#" in link:
link = re.sub(r"#.*$", "", link)
if "global_collection_id" in link:
return await _get_user_list_detail_2(re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", link))
if "gcid_" in link:
match = re.search(r"gcid_\w+", link)
if match:
gcid_id = await _decode_gcid(match.group(0))
if gcid_id:
return await _get_user_list_detail_2(gcid_id)
if "chain=" in link:
return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", link), page)
if ".html" in link:
if "zlist.html" in link:
link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", link)
if "pagesize" in link:
link = link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace("page=1", f"page={page}")
else:
link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}"
elif "song.html" not in link:
return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", link), page)
resp = await http_fetch(
link,
headers={
"User-Agent": (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
),
"Referer": link,
},
)
location = resp.header("location") or None
if resp.status_code > 400:
return await _get_user_list_detail(link, page, retry_num + 1)
if location:
if "global_collection_id" in location:
return await _get_user_list_detail_2(
re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", location)
)
if "gcid_" in location:
match = re.search(r"gcid_\w+", link)
if match:
gcid_id = await _decode_gcid(match.group(0))
if gcid_id:
return await _get_user_list_detail_2(gcid_id)
if "chain=" in location:
return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", location), page)
if ".html" in location:
if "zlist.html" in location:
next_link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", location)
if "pagesize" in next_link:
next_link = next_link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace(
"page=1", f"page={page}"
)
else:
next_link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}"
return await _get_user_list_detail(next_link, page, retry_num + 1)
return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", location), page)
return await _get_user_list_detail(location, page, retry_num + 1)
if isinstance(resp.body, str):
body_text = resp.body
global_id = re.search(r'"global_collection_id":"(\w+)"', body_text)
gcid_id = global_id.group(1) if global_id else None
if not gcid_id:
match = re.search(r'"encode_gic":"(\w+)"', body_text) or re.search(r'"encode_src_gid":"(\w+)"', body_text)
if match:
gcid_id = await _decode_gcid(match.group(1))
if not gcid_id:
raise RuntimeError("get list error")
return await _get_user_list_detail_2(gcid_id)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("errcode") != 0:
return await _get_user_list_detail(link, page, retry_num + 1)
return await _get_user_list_detail_by_link(body, link)
async def get_list_detail(id: Any, page: int | None = None) -> dict[str, Any] | None:
"""歌单详情入口:按 id 形态分派到特殊页 / 链接 / 酷狗码。"""
value = str(id)
page_no = page or 1
if "special/single/" in value:
value = _LIST_DETAIL_LINK_EXP.sub(r"\1", value)
elif re.search(r"https?:", value):
# 修复形如 https://www.kugou.com/songlist/xxx/?uid=xxx&cover=http%3A%2F%2F... 的脏链接
return await _get_user_list_detail(re.sub(r"^.*?http", "http", value), page_no)
elif re.fullmatch(r"\d+", value):
return await _get_user_list_detail_by_code(value)
elif value.startswith("id_"):
value = value.replace("id_", "")
return await get_list_detail_by_special_id(value, page_no)
+107
View File
@@ -0,0 +1,107 @@
"""酷狗通用请求封装与签名(对应 sdk/kg/util.ts + sdk/auth/kg.ts 的设备/签名部分)。
`app/sdk/auth/kg.py` 仍是登录流程的 stub(禁止修改),因此设备指纹与 android/web
签名在这里单独实现:取流(apis/kg.py)与取流兜底都要用同一套指纹,放这里避免重复。
"""
from __future__ import annotations
import hashlib
import uuid
from typing import Any
from urllib.parse import quote
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import to_md5
ANDROID_SALT = "OIlwieks28dk2k092lksi2UIkp"
WEB_SALT = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt"
_RANDOM_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
def signature_params(params: str, platform: str = "android", body: str = "") -> str:
"""按 key 排序拼接后以盐包裹取 MD5(android 与 web 只是盐不同)。"""
key = WEB_SALT if platform == "web" else ANDROID_SALT
items = params.split("&")
items.sort()
return to_md5(f"{key}{''.join(items)}{body}{key}")
def android_sign(params: dict[str, Any], data: str = "") -> str:
"""网关签名:`${ANDROID_SALT}排序后的k=v拼接${data}${ANDROID_SALT}` 的 MD5。"""
import json
parts = []
for key in sorted(params):
value = params[key]
# JSON.stringify 是紧凑格式(无空格),登录/注册签名依赖逐字节一致
encoded = (
json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, (dict, list)) else value
)
parts.append(f"{key}={encoded}")
return to_md5(f"{ANDROID_SALT}{''.join(parts)}{data}{ANDROID_SALT}")
def encode_uri_component(value: Any) -> str:
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
return quote(str(value), safe="!~*'()")
def random_string(n: int) -> str:
import random
return "".join(_RANDOM_CHARS[random.randrange(len(_RANDOM_CHARS))] for _ in range(n))
def pkcs7_pad(data: bytes, block: int = 16) -> bytes:
"""PKCS#7 填充 —— Node 的 createCipheriv 会自动补,cryptography 不会。"""
remain = block - (len(data) % block)
return data + bytes([remain]) * remain
def pkcs7_unpad(data: bytes, block: int = 16) -> bytes:
"""PKCS#7 去填充(对应 Node createDecipheriv 的自动 unpad)。"""
if not data:
return data
remain = data[-1]
if remain < 1 or remain > block or len(data) < remain:
return data
return data[:-remain]
def get_device() -> dict[str, Any]:
"""设备指纹持久化:guid/mid/dfid 稳定复用(取流与登录共用同一指纹)。"""
dev = get_credentials("_kg_device")
if not dev:
dev = {"guid": str(uuid.uuid4()), "mid": "", "dfid": random_string(24)}
if not dev.get("mid"):
dev["mid"] = str(int(hashlib.md5(str(dev.get("guid") or "").encode()).hexdigest(), 16))
set_credentials("_kg_device", dev)
if not dev.get("dfid"):
dev["dfid"] = random_string(24)
return dev
async def create_http_fetch(url: str, options: dict[str, Any] | None = None, retry_num: int = 0) -> Any:
"""带重试的请求:非 200 或 errcode 非 0 一律重试(最多 3 次)。
成功时按原实现的取值优先级返回:body.data → body.info(数组时返回整 body) → body.info。
"""
if retry_num > 2:
raise RuntimeError("try max num")
try:
result = await http_fetch(url, **(options or {}))
except Exception as err: # noqa: BLE001 - 可降级失败:换一次请求重试
print(f"[kg] 请求失败将重试({retry_num + 1}/3) {url}: {err}")
return await create_http_fetch(url, options, retry_num + 1)
body = result.body if isinstance(result.body, dict) else {}
err_code = body.get("error_code", body.get("errcode", body.get("err_code")))
if result.status_code != 200 or err_code != 0:
return await create_http_fetch(url, options, retry_num + 1)
if body.get("data"):
return body["data"]
if isinstance(body.get("info"), list):
return body
return body.get("info")
+95
View File
@@ -0,0 +1,95 @@
"""酷我音乐(对应 sdk/kw/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 专辑 / 歌单 / 解析,
顶层挂 get_music_url(取流在 apis/kw.py)、get_lyric、get_pic、get_music_info、
handle_music_info、get_music_detail_page_url。
酷我没有歌手主页子模块(TS 侧也没有),`singer` 能力位留空,路由据此返回 404。
"""
from __future__ import annotations
from typing import Any
from app.sdk.apis.kw import get_music_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms._base import Platform
from app.sdk.platforms.kw import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_search,
parse,
pic,
playlist,
song_list,
)
from app.sdk.platforms.kw.util import format_singer
MUSIC_INFO_API = "http://www.kuwo.cn/api/www/music/musicInfo?mid={songmid}"
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""歌曲详情(歌名/歌手/封面/专辑)。上游 code != 200 时抛出 msg 原文。"""
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(MUSIC_INFO_API.format(songmid=songmid))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") == 200:
return body.get("data") or {}
raise RuntimeError(str(body.get("msg") or "获取歌曲信息失败"))
async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""用 get_music_info 就地补全 name / singer / img / albumName,与原实现一致。"""
info = await get_music_info(song_info)
song_info["name"] = info.get("name")
song_info["singer"] = format_singer(str(info.get("artist") or ""))
song_info["img"] = info.get("pic")
song_info["albumName"] = info.get("album")
return song_info
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"http://www.kuwo.cn/play_detail/{song_info.get('songmid') or ''}"
async def get_music_urls(music_info: dict[str, Any]) -> dict[str, str]:
"""批量取各档直链,返回 `{type: url}`(原 getMusicUrls 的回调语义)。"""
import asyncio
types = music_info.get("types") or []
songmid = str(music_info.get("songmid") or "")
results = await asyncio.gather(
*(get_music_url({"songmid": songmid}, str(item.get("type") or "")) for item in types),
return_exceptions=True,
)
type_url: dict[str, str] = {}
for res in results:
if isinstance(res, BaseException):
continue
if res.get("url"):
type_url[str(res.get("type"))] = str(res["url"])
return type_url
MODULE = Platform(
id="kw",
name="酷我音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
album=album,
playlist=playlist,
parse=parse,
get_music_url=get_music_url,
get_music_urls=get_music_urls,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=get_music_info,
handle_music_info=handle_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+251
View File
@@ -0,0 +1,251 @@
"""酷我专辑(对应 sdk/kw/album.ts)。
上游 `search.kuwo.cn/r.s?stype=albuminfo` 返回伪 JSON(单引号),服务端固定
1000 条/页。对外两个入口:
* `get_album_list_detail(id, page)` —— 单页直取,第三参数是 retry_num
* `get_album_detail(id, page, limit)` —— 统一形状,limit 小于 1000 时本地开窗
路由按 hasattr 分流(`app/routes/music.py:584`),两个名字都要提供。
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
from app.sdk.platforms.kw.util import format_singer, obj_str2json
LIST_LIMIT = 36
SONG_LIMIT = 1000
# 本地开窗最多翻到第 50 个服务端页,避免异常 total 导致长循环
MAX_SERVER_PAGES = 50
DETAIL_API = (
"http://search.kuwo.cn/r.s?pn={pn}&rn={rn}&stype=albuminfo&albumid={id}"
"&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0"
)
SEARCH_API = (
"http://search.kuwo.cn/r.s?all={keyword}&ft=album&itemset=web_2013&client=kt"
"&pcmp4=1&geo=c&vipver=1&pn={pn}&rn={rn}&rformat=json&encoding=utf8"
)
# `formats` 字段是 `|` 分隔的音质标记;这里只认对外暴露的四个档位
_FORMAT_QUALITY = (
("MP3128", "128k"),
("MP3H", "320k"),
("ALFLAC", "flac"),
("HIRFLAC", "flac24bit"),
)
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _filter_list_detail(raw_list: list[dict[str, Any]], album_name: str, album_id: Any) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
formats = str(item.get("formats") or "").split("|")
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for marker, quality in _FORMAT_QUALITY:
if marker not in formats:
continue
types.append({"type": quality, "size": None})
_types[quality] = {"size": None}
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": album_name,
"albumId": album_id,
"songmid": item.get("id"),
"source": "kw",
# 专辑接口不返回时长/封面,宁可为空也不猜
"interval": None,
"img": item.get("pic"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
async def get_album_list_detail(album_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
url = DETAIL_API.format(pn=page - 1, rn=SONG_LIMIT, id=album_id)
resp = await http_fetch(url)
if resp.status_code != 200:
return await get_album_list_detail(album_id, page, retry_num + 1)
body = obj_str2json(resp.raw.decode("utf-8", errors="replace"))
if not body.get("musiclist"):
return await get_album_list_detail(album_id, page, retry_num + 1)
album_name = decode_name(body.get("name"))
return {
"list": _filter_list_detail(body["musiclist"], album_name, body.get("albumid")),
"page": page,
"limit": SONG_LIMIT,
"total": _to_int(body.get("songnum")),
"source": "kw",
"info": {
"name": album_name,
"img": body.get("img") or body.get("hts_img"),
"desc": decode_name(body.get("info")),
"author": decode_name(body.get("artist")),
},
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 1000) -> dict[str, Any]:
"""统一形状的专辑详情。limit >= 1000 时服务端页大小够用,直通单页接口。"""
album_key = str(album_id or "").strip()
if not album_key:
raise RuntimeError("酷我专辑 id 为空")
page_no = max(1, _to_int(page) or 1)
page_size = max(1, _to_int(limit) or SONG_LIMIT)
if page_size >= SONG_LIMIT:
return await get_album_list_detail(album_key, page_no)
start = (page_no - 1) * page_size
server_page = start // SONG_LIMIT + 1
skip = start % SONG_LIMIT
songs: list[dict[str, Any]] = []
info: dict[str, Any] | None = None
total = 0
while len(songs) < page_size and server_page <= MAX_SERVER_PAGES:
res = await get_album_list_detail(album_key, server_page)
if not res:
break
if info is None:
info = res.get("info")
total = _to_int(res.get("total")) or total
raw_list = res.get("list") or []
if not raw_list:
break
songs.extend(raw_list[skip : skip + (page_size - len(songs))])
skip = 0
if len(raw_list) < SONG_LIMIT:
break
if total > 0 and len(songs) >= total:
break
server_page += 1
return {
"source": "kw",
"info": info or {"name": album_key, "img": "", "desc": "", "author": ""},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total or len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
"""聚合全部分页曲目并按 songmid 去重。"""
album_key = str(album_id or "").strip()
if not album_key:
raise RuntimeError("酷我专辑 id 为空")
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for page in range(1, MAX_SERVER_PAGES + 1):
res = await get_album_list_detail(album_key, page)
raw_list = res.get("list") or []
for song in raw_list:
key = str(song.get("songmid") or "")
if key and key in seen:
continue
if key:
seen.add(key)
songs.append(song)
total = _to_int(res.get("total"))
if len(raw_list) < SONG_LIMIT:
break
if total > 0 and len(songs) >= total:
break
return songs
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0}
page_no = max(1, _to_int(page) or 1)
page_size = max(1, _to_int(limit) or 10)
resp = await http_fetch(SEARCH_API.format(keyword=quote(word, safe=""), pn=page_no - 1, rn=page_size))
if resp.status_code != 200:
raise RuntimeError("酷我专辑搜索失败")
text = resp.raw.decode("utf-8", errors="replace")
try:
data = obj_str2json(text)
except Exception as err:
raise RuntimeError("酷我专辑搜索失败") from err
if not data.get("albumlist"):
raise RuntimeError("酷我专辑搜索失败")
result: list[dict[str, Any]] = []
for item in data["albumlist"]:
album_key = _first_non_empty(item.get("albumid"), item.get("id"))
if not album_key:
continue
result.append(
{
"id": album_key,
"name": decode_name(_first_non_empty(item.get("name"))),
"cover": _first_non_empty(item.get("hts_img"), item.get("img")),
"trackCount": _to_int(item.get("musiccnt")),
"playCount": _to_int(item.get("PLAYCNT") or item.get("playcnt")),
"creator": decode_name(_first_non_empty(item.get("aartist"), item.get("artist"))),
"description": decode_name(_first_non_empty(item.get("info"))),
"link": f"http://www.kuwo.cn/album_detail/{album_key}",
"source": "kw",
"type": "album",
"company": decode_name(_first_non_empty(item.get("company"))),
"publishTime": _first_non_empty(item.get("pub")),
}
)
return {
"source": "kw",
"list": result,
"page": page_no,
"limit": page_size,
"total": _to_int(data.get("TOTAL") or data.get("total")) or len(result),
}
async def parse_album_link(link: str) -> dict[str, Any]:
raw = str(link or "")
for pattern in (r"album_detail/(\d+)", r"album/(\d+)", r"albumid=(\d+)"):
match = re.search(pattern, raw)
if match:
return await get_album_detail(match.group(1), 1)
raise RuntimeError("无效的酷我专辑链接")
__all__ = [
"get_album_detail",
"get_album_list_detail",
"get_album_songs",
"parse_album_link",
"search_album",
]
+89
View File
@@ -0,0 +1,89 @@
"""酷我评论(对应 sdk/kw/comment.ts)。
`ncomment.kuwo.cn/com.s` 只认安卓 Dalvik UA;`type=get_comment` 取最新评论、
`type=get_rec_comment` 取热评,两者响应字段名不同(comments/comments_counts
vs hot_comments/hot_comments_counts)。
"""
from __future__ import annotations
import math
from typing import Any
from urllib.parse import unquote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2
COMMENT_API = (
"http://ncomment.kuwo.cn/com.s?f=web&type={type}&aapiver=1&prod=kwplayer_ar_10.5.2.0"
"&digest=15&sid={songmid}&start={start}&msgflag=1&count={count}&newver=3&uid=0"
)
HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"}
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _reply_comment(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": item.get("id"),
"text": item.get("msg"),
"time": item.get("time"),
"timeStr": date_format2(_to_int(item.get("time")) * 1000),
"userName": item.get("u_name"),
"avatar": item.get("u_pic"),
"userId": item.get("u_id"),
"likedCount": item.get("like_num"),
"images": [item["mpic"]] if item.get("mpic") else [],
}
def _filter_comment(raw_list: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
if not raw_list:
return []
result: list[dict[str, Any]] = []
for item in raw_list:
data = _reply_comment(item)
data["images"] = [unquote(str(item["mpic"]))] if item.get("mpic") else []
# 顶层评论多一个楼中楼数组;原实现对父级 mpic 做 decodeURIComponent,子级不做
data["reply"] = [_reply_comment(child) for child in item.get("child_comments") or []]
result.append(data)
return result
async def _get_comment(songmid: str, page: int, limit: int, *, hot: bool) -> dict[str, Any]:
comment_type = "get_rec_comment" if hot else "get_comment"
url = COMMENT_API.format(type=comment_type, songmid=songmid, start=limit * (page - 1), count=limit)
resp = await http_fetch(url, headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or str(body.get("code")) != "200":
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
if hot:
total = _to_int(body.get("hot_comments_counts"))
comments = _filter_comment(body.get("hot_comments"))
else:
total = _to_int(body.get("comments_counts"))
comments = _filter_comment(body.get("comments"))
return {
"source": "kw",
"comments": comments,
"total": total,
"page": page,
"limit": limit,
# 原实现用 `Math.ceil(total/limit) || 1` 兜住 0 条
"maxPage": math.ceil(total / limit) if limit and total else 1,
}
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=False)
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]:
return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=True)
+28
View File
@@ -0,0 +1,28 @@
"""酷我热搜(对应 sdk/kw/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
HOT_API = (
"http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2"
"&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1&notrace=0&uid=0"
"&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1"
)
# 该接口只认移动端 Dalvik UA,桌面 UA 会返回非 JSON
HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"}
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
return [str(item.get("key") or "") for item in raw_list]
async def get_list() -> dict[str, Any]:
resp = await http_fetch(HOT_API, headers=HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("status") != "ok":
raise RuntimeError("获取热搜词失败")
return {"source": "kw", "list": _filter_list(body.get("tagvalue") or [])}
+155
View File
@@ -0,0 +1,155 @@
"""酷我榜单(对应 sdk/kw/leaderboard.ts)。
`get_boards` 直接返回硬编码榜单表(不发请求),id 形如 `kw__93`(路由层会剥掉前缀
再把裸 bangid 传回 `get_list`)。详情走 `wbd.kuwo.cn/api/bd/bang/bang_info`:
请求体整体 AES-128-ECB 加密 + 签名,响应同样是加密体,`util.wbd_*` 负责双向转换。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kw.util import format_singer, wbd_build_param, wbd_decode_data
LIST_DETAIL_LIMIT = 100
BOARD_LIST: list[dict[str, str]] = [
{"id": "kw__93", "name": "飙升榜", "bangid": "93"},
{"id": "kw__17", "name": "新歌榜", "bangid": "17"},
{"id": "kw__16", "name": "热歌榜", "bangid": "16"},
{"id": "kw__158", "name": "抖音热歌榜", "bangid": "158"},
{"id": "kw__292", "name": "铃声榜", "bangid": "292"},
{"id": "kw__284", "name": "热评榜", "bangid": "284"},
{"id": "kw__290", "name": "ACG新歌榜", "bangid": "290"},
{"id": "kw__286", "name": "台湾KKBOX榜", "bangid": "286"},
{"id": "kw__279", "name": "冬日暖心榜", "bangid": "279"},
{"id": "kw__281", "name": "巴士随身听榜", "bangid": "281"},
{"id": "kw__255", "name": "KTV点唱榜", "bangid": "255"},
{"id": "kw__280", "name": "家务进行曲榜", "bangid": "280"},
{"id": "kw__282", "name": "熬夜修仙榜", "bangid": "282"},
{"id": "kw__283", "name": "枕边轻音乐榜", "bangid": "283"},
{"id": "kw__278", "name": "古风音乐榜", "bangid": "278"},
{"id": "kw__264", "name": "Vlog音乐榜", "bangid": "264"},
{"id": "kw__242", "name": "电音榜", "bangid": "242"},
{"id": "kw__187", "name": "流行趋势榜", "bangid": "187"},
{"id": "kw__204", "name": "现场音乐榜", "bangid": "204"},
{"id": "kw__186", "name": "ACG神曲榜", "bangid": "186"},
{"id": "kw__185", "name": "最强翻唱榜", "bangid": "185"},
{"id": "kw__26", "name": "经典怀旧榜", "bangid": "26"},
{"id": "kw__104", "name": "华语榜", "bangid": "104"},
{"id": "kw__182", "name": "粤语榜", "bangid": "182"},
{"id": "kw__22", "name": "欧美榜", "bangid": "22"},
{"id": "kw__184", "name": "韩语榜", "bangid": "184"},
{"id": "kw__183", "name": "日语榜", "bangid": "183"},
{"id": "kw__145", "name": "会员畅听榜", "bangid": "145"},
{"id": "kw__153", "name": "网红新歌榜", "bangid": "153"},
{"id": "kw__64", "name": "影视金曲榜", "bangid": "64"},
{"id": "kw__176", "name": "DJ嗨歌榜", "bangid": "176"},
{"id": "kw__106", "name": "真声音", "bangid": "106"},
{"id": "kw__12", "name": "Billboard榜", "bangid": "12"},
{"id": "kw__49", "name": "iTunes音乐榜", "bangid": "49"},
{"id": "kw__180", "name": "beatport电音榜", "bangid": "180"},
{"id": "kw__13", "name": "英国UK榜", "bangid": "13"},
{"id": "kw__164", "name": "百大DJ榜", "bangid": "164"},
{"id": "kw__246", "name": "YouTube音乐排行榜", "bangid": "246"},
{"id": "kw__265", "name": "韩国Genie榜", "bangid": "265"},
{"id": "kw__14", "name": "韩国M-net榜", "bangid": "14"},
{"id": "kw__8", "name": "香港电台榜", "bangid": "8"},
{"id": "kw__15", "name": "日本公信榜", "bangid": "15"},
{"id": "kw__151", "name": "腾讯音乐人原创榜", "bangid": "151"},
]
BANG_API = "https://wbd.kuwo.cn/api/bd/bang/bang_info"
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
# 榜单列表按音质等级升序排列(128k → 320k → flac → flac24bit)
_QUALITY_RANK = {"flac24bit": 4, "flac": 3, "320k": 2, "128k": 1}
_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
def _filter_data(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, str]] = []
_types: dict[str, dict[str, str]] = {}
seen: set[str] = set()
for entry in str(item.get("n_minfo") or "").split(";"):
match = _MININFO_RE.search(entry)
if match is None:
continue
bitrate = match.group(2)
if bitrate in seen:
continue
seen.add(bitrate)
quality = _BITRATE_QUALITY.get(bitrate)
if quality is None:
continue
# 原实现 types 留原样、_types 转大写,两者不完全一致
types.append({"type": quality, "size": match.group(4)})
_types[quality] = {"size": match.group(4).upper()}
types.sort(key=lambda entry: _QUALITY_RANK[entry["type"]])
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": decode_name(item.get("album")),
"albumId": item.get("albumId"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": item.get("pic"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "kw"}
async def get_list(board_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
retry_num += 1
if retry_num > 3:
raise RuntimeError("try max num")
body = {
"uid": "",
"devId": "",
"sFrom": "kuwo_sdk",
"user_type": "AP",
"carSource": "kwplayercar_ar_6.0.1.0_apk_keluze.apk",
"id": board_id,
"pn": page - 1,
"rn": LIST_DETAIL_LIMIT,
}
resp = await http_fetch(f"{BANG_API}?{wbd_build_param(body)}")
# 原实现直接调 decodeData,解密失败即 reject、不重试,这里保持一致
raw_data = wbd_decode_data(resp.body) if isinstance(resp.body, str) else {}
data = raw_data.get("data") or {}
if resp.status_code != 200 or raw_data.get("code") != 200 or not data.get("musiclist"):
return await get_list(board_id, page, retry_num)
return {
"total": _to_int(data.get("total")),
"list": _filter_data(data["musiclist"]),
"limit": LIST_DETAIL_LIMIT,
"page": page,
"source": "kw",
}
+124
View File
@@ -0,0 +1,124 @@
"""酷我歌词(对应 sdk/kw/lyric.ts)。
`newlyric.kuwo.cn/newlyric.lrc?<params>`:params 是明文字符串与 `yeelion` 逐字节异或
后的 base64。响应体是 zlib 压缩 + 可选 yeelion 异或,由 `lyric_decode` 还原。
歌词分两种形态:普通 lrc(`[mm:ss.xx]文本`)与 lrcx/逐字(每行带 `<start,dur>` 标记)。
lrcx 走 `util.parse_yrc` 归一到 `yrc` 字段,`lyric`/`tlyric` 里的逐字标记则剥掉,
保持纯 lrc 可直接给播放器。
"""
from __future__ import annotations
import base64
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
from app.sdk.platforms.kw.lyric_decode import handle_kw_decode_lyric
from app.sdk.platforms.kw.util import parse_yrc
_BUF_KEY = b"yeelion"
LYRIC_API = "http://newlyric.kuwo.cn/newlyric.lrc"
_TIME_EXP = re.compile(r"^\[([\d:.]*)\]")
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
_EXIST_TIME_EXP = re.compile(r"\[\d{1,2}:.*\d{1,4}\]")
_LYRICX_TAG = re.compile(r"^<-?\d+,-?\d+>")
_WORD_TIME_ALL = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
def build_params(songmid: str, is_get_lyricx: bool) -> str:
"""明文参数与 `yeelion` 循环异或后 base64。
原实现用 `new Uint16Array` 中转再 `Buffer.from(output)`;Node 对 TypedArray
走 fromArrayLike,按元素截断成 8 位字节,等价于这里的逐字节异或。
"""
params = f"user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_{songmid}"
if is_get_lyricx:
params += "&lrcx=1"
raw = params.encode()
return base64.b64encode(bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(raw))).decode()
def _sort_lrc_arr(arr: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""按时间去重:重复时间视为翻译行,挤进 lrc_t。
全部行都没有逐字标记、且「翻译行」占比异常时,认为数据错乱并抛 failed。
"""
lrc_set: set[str] = set()
lrc: list[dict[str, str]] = []
lrc_t: list[dict[str, str]] = []
is_lyricx = False
for item in arr:
if item["time"] in lrc_set:
if len(lrc) < 2:
continue
t_item = lrc.pop()
t_item["time"] = lrc[-1]["time"]
lrc_t.append(t_item)
lrc.append(item)
else:
lrc.append(item)
lrc_set.add(item["time"])
if not is_lyricx and _LYRICX_TAG.match(item["text"]):
is_lyricx = True
if not is_lyricx and len(lrc_t) > len(lrc) * 0.3 and len(lrc) - len(lrc_t) > 6:
raise RuntimeError("failed")
return lrc, lrc_t
def _transform_lrc(tags: list[str], lrc_list: list[dict[str, str]]) -> str:
body = "".join(f"[{item['time']}]{item['text']}\n" for item in lrc_list)
return "\n".join(tags) + "\n" + body
def parse_lrc(lrc: str) -> dict[str, str]:
tags: list[str] = []
arr: list[dict[str, str]] = []
for raw_line in re.split(r"\r\n|\r|\n", lrc):
line = raw_line.strip()
match = _TIME_EXP.match(line)
if match:
text = _TIME_EXP.sub("", line, count=1).strip()
time = match.group(1)
# 只有两位小数的补一个 0,与三位/更长的保持同宽
if re.search(r"\.\d\d$", time):
time += "0"
arr.append({"time": time, "text": text})
elif _TAG_LINE.search(line):
tags.append(line)
lrc_list, lrc_t = _sort_lrc_arr(arr)
return {
"lyric": decode_name(_transform_lrc(tags, lrc_list)),
"tlyric": decode_name(_transform_lrc(tags, lrc_t)) if lrc_t else "",
}
async def get_lyric(song_info: dict[str, Any], is_get_lyricx: bool = True) -> dict[str, str]:
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(f"{LYRIC_API}?{build_params(songmid, is_get_lyricx)}")
if resp.status_code != 200:
raise RuntimeError(json.dumps(resp.body, ensure_ascii=False))
base64_data = await handle_kw_decode_lyric(base64.b64encode(resp.raw).decode(), is_get_lyricx)
try:
lrc_info = parse_lrc(base64.b64decode(base64_data).decode("utf-8", errors="replace"))
except Exception as err:
raise RuntimeError("Get lyric failed") from err
if lrc_info.get("tlyric"):
lrc_info["tlyric"] = _WORD_TIME_ALL.sub("", lrc_info["tlyric"])
# yrc 解析失败只代表这份歌词没有逐字数据,不影响普通歌词返回
try:
lrc_info["yrc"] = parse_yrc(lrc_info["lyric"])
except Exception: # noqa: BLE001
lrc_info["yrc"] = ""
lrc_info["lyric"] = _WORD_TIME_ALL.sub("", lrc_info["lyric"])
if not _EXIST_TIME_EXP.search(lrc_info["lyric"]):
raise RuntimeError("Get lyric failed")
return lrc_info
+38
View File
@@ -0,0 +1,38 @@
"""酷我歌词解码(对应 sdk/kw/lyricDecode.ts)。
响应体前缀是 `tp=content`,`\\r\\n\\r\\n` 之后为 zlib 压缩数据;非 lrcx(普通歌词)
直接按 gb18030 解码,lrcx(逐字歌词)先 base64 解出、再与 `yeelion` 逐字节异或,
最后按 gb18030 解码。算法来源为酷我桌面端歌词解码实现。
"""
from __future__ import annotations
import base64
import zlib
_BUF_KEY = b"yeelion"
def _xor_yeelion(data: bytes) -> bytes:
"""逐字节与 `yeelion` 循环异或(原实现用双层 while,等价于 i % len(key))。"""
return bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(data))
async def decode_kw_lyric(buf: bytes, is_get_lyricx: bool) -> str:
"""解出明文歌词。非酷我歌词格式返回空串(与原实现一致)。"""
if buf[:10].decode("utf-8", errors="replace") != "tp=content":
return ""
# 找不到分隔时原实现 indexOf 返回 -1,+4 后从第 3 字节开始切片,这里保持一致
lrc_data = zlib.decompress(buf[buf.find(b"\r\n\r\n") + 4 :])
if not is_get_lyricx:
return lrc_data.decode("gb18030", errors="replace")
raw = base64.b64decode(lrc_data.decode("utf-8", errors="replace"))
return _xor_yeelion(raw).decode("gb18030", errors="replace")
async def handle_kw_decode_lyric(lrc_base64: str, is_get_lyricx: bool) -> str:
"""解码入口:入参与返回值都是 base64(对应原 handleKwDecodeLyric)。"""
lrc = await decode_kw_lyric(base64.b64decode(lrc_base64), is_get_lyricx)
return base64.b64encode(lrc.encode()).decode()
+116
View File
@@ -0,0 +1,116 @@
"""酷我搜索(对应 sdk/kw/musicSearch.ts)。
搜索接口 `search.kuwo.cn/r.s` 返回的是带单引号的 JS 对象字面量,必须走
`util.obj_str2json` 而不能直接 json.loads。音质清单在 `N_MINFO` 字段里,
`;` 分隔多档、每档形如 `level:ff,bitrate:2000,format:flac,size:32.43Mb`;
`mflac`/`mgg`/`mmp4`/`zp` 等加密码流的 bitrate 是 20000+ 的随机值,不在
4000/2000/320/128 四档之内,天然被过滤掉。
"""
from __future__ import annotations
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kw.util import format_singer, obj_str2json
DEFAULT_LIMIT = 30
SEARCH_API = (
"http://search.kuwo.cn/r.s?client=kt&all={keyword}&pn={pn}&rn={rn}"
"&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1"
"&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1"
)
# bitrate 精确值 → 对外音质名。只认这四档,加密档位一律丢弃。
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
_MINFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""`N_MINFO` 缺失时返回 None(原文用 console.log 提示后触发重试)。"""
result: list[dict[str, Any]] = []
for info in raw_list:
n_minfo = info.get("N_MINFO")
if not n_minfo:
return None
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for entry in str(n_minfo).split(";"):
match = _MINFO_RE.search(entry)
if match is None:
continue
quality = _BITRATE_QUALITY.get(match.group(2))
if quality is None:
continue
size = match.group(4)
types.append({"type": quality, "size": size})
_types[quality] = {"size": size.upper()}
# 原文按 N_MINFO 的书写顺序收集后整体反转(加密档在前,明文档在后)
types.reverse()
interval = _to_int(info.get("DURATION"))
result.append(
{
"name": decode_name(info.get("SONGNAME")),
"singer": format_singer(decode_name(info.get("ARTIST"))),
"source": "kw",
"songmid": str(info.get("MUSICRID") or "").replace("MUSIC_", ""),
"albumId": decode_name(info.get("ALBUMID") or ""),
"interval": format_play_time(interval),
"albumName": decode_name(info.get("ALBUM")) if info.get("ALBUM") else "",
"lrc": None,
"img": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _to_int(value: Any) -> int:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return 0
async def search(keyword: str, page: int = 1, limit: int | None = None, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=page_size)
resp = await http_fetch(url)
# 上游偶发返回非 JSON(风控页);原文靠 `!result` 兜住并重试
result = resp.body
if isinstance(result, str):
# rformat=json 正常回合法 JSON,但风控页/旧版接口会回单引号伪 JSON
try:
result = obj_str2json(result)
except ValueError:
return await search(keyword, page, limit, retry_num + 1)
if not isinstance(result, dict):
return await search(keyword, page, limit, retry_num + 1)
if result.get("TOTAL") != "0" and result.get("SHOW") == "0":
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(result.get("abslist") or [])
if songs is None:
return await search(keyword, page, limit, retry_num + 1)
total = _to_int(result.get("TOTAL"))
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"total": total,
"limit": page_size,
"source": "kw",
}
+117
View File
@@ -0,0 +1,117 @@
"""酷我链接解析(对应 sdk/kw/parse.ts)。
歌曲:`www.kuwo.cn/play_detail/<rid>`、`m.kuwo.cn/h5app/song/<rid>`、
`musicId=<rid>` 或裸 rid(4 位以上数字)。元数据取
`m.kuwo.cn/newh5/singles/songinfoandlrc`,风控时会回 status 301「音乐查询失败」,
按 600ms×(n+1) 退避重试最多 5 次(约 9 秒)。
直链是尽力的:版权/风控导致取流失败时仍返回元数据(与上游 fetchAudioURL 降级一致)。
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time
from app.sdk.platforms.kw import album, playlist
from app.sdk.platforms.kw.util import format_singer
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
SONG_PATTERNS = (
re.compile(r"play_detail/(\d+)"),
re.compile(r"h5app/song/(\d+)"),
re.compile(r"musicId=(\d+)"),
re.compile(r"^\d{4,}$"),
)
META_API = "http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId={rid}&httpsStatus=1"
MAX_META_RETRY = 5
def extract_song_id(link: Any) -> str:
raw = str(link if link is not None else "").strip()
for pattern in SONG_PATTERNS:
match = pattern.search(raw)
if match:
return match.group(1) if match.groups() else raw
return ""
def _to_int(value: Any) -> int:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return 0
async def fetch_song_meta(rid: str, retry_num: int = 0) -> dict[str, Any]:
resp = await http_fetch(
META_API.format(rid=rid),
method="get",
headers={"User-Agent": _UA, "Cookie": "wd=1;vip=0"},
timeout=15_000,
)
data: Any = resp.body
if isinstance(data, str):
try:
data = json.loads(data)
except ValueError:
data = None
songinfo = ((data or {}).get("data") or {}).get("songinfo")
if not songinfo:
if retry_num < MAX_META_RETRY:
await asyncio.sleep(0.6 * (retry_num + 1))
return await fetch_song_meta(rid, retry_num + 1)
msg = (data or {}).get("msg") if isinstance(data, dict) else None
raise RuntimeError(msg or "酷我歌曲不存在或已下架")
return songinfo
async def parse_song(link: str) -> dict[str, Any]:
rid = extract_song_id(link)
if not rid:
raise RuntimeError("无效的酷我歌曲链接")
meta = await fetch_song_meta(rid)
duration = _to_int(meta.get("duration"))
album_id = meta.get("albumId")
song: dict[str, Any] = {
"songmid": rid,
"name": meta.get("songName") or f"Kuwo_Song_{rid}",
"singer": format_singer(str(meta.get("artist") or "").strip()),
"albumName": str(meta.get("album") or "").strip(),
"albumId": "" if album_id is None else str(album_id),
"interval": format_play_time(duration) if duration > 0 else "--/--",
"img": meta.get("pic") or None,
"lrc": None,
"otherSource": None,
"types": [],
"_types": {},
"typeUrl": {},
"source": "kw",
"extra": {"rid": rid, "link": f"http://www.kuwo.cn/play_detail/{rid}"},
}
try:
from app.sdk.apis.kw import get_music_url
hit = await get_music_url({"songmid": rid}, "128k")
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 版权/风控取流失败时仍返回元数据
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)
+22
View File
@@ -0,0 +1,22 @@
"""酷我封面(对应 sdk/kw/pic.ts)。
`artistpicserver.kuwo.cn/pic.web` 返回的是一段纯文本(不是 JSON),内容就是图片 URL;
取不到时返回 `None`(原实现用 `/^http/` 判定,不匹配即 null)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
PIC_API = "http://artistpicserver.kuwo.cn/pic.web"
PIC_PARAMS = "corp=kuwo&type=rid_pic&pictype=500&size=500"
async def get_pic(song_info: dict[str, Any]) -> str | None:
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(f"{PIC_API}?{PIC_PARAMS}&rid={songmid}")
text = resp.body if isinstance(resp.body, str) else ""
text = text.strip()
return text if text.startswith("http") else None
+255
View File
@@ -0,0 +1,255 @@
"""酷我歌单(对应 sdk/kw/playlist.ts)。
在 `song_list` 之上做统一形状:
* `get_playlist_detail(id, page, limit)` —— 服务端 1000 条/页,本地按 page/limit 开窗
* `get_playlist_songs(id)` —— 聚合全部页曲目
* `search_playlist` / `get_categories` / `get_category_playlists` / `get_recommended`
推荐(getRcmPlayList)服务端 limit 固定 36,显式更小的 limit 在本地截断。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.platforms.kw import song_list
SERVER_PAGE_SIZE = song_list.SONG_LIMIT
MAX_SERVER_PAGES = 60
_ID_PATTERNS = (
re.compile(r"playlist_detail/(\d+)"),
re.compile(r"/playlist/(\d+)"),
re.compile(r"playlistId=(\d+)"),
)
_DIGEST_PREFIX_RE = re.compile(r"^digest-\w+__")
def _js_number_or_raw(value: Any, default: Any = 0) -> Any:
"""复现 JS 的 `Number(x) → isFinite ? 数字 : 原值` 语义。
`format_play_count` 返回的是 `"1.2万"` 这类字符串,`Number("1.2万")` 是 NaN,
原实现于是把原字符串原样传下去;能解析成数字的才转成数字。
"""
if value is None or value == "":
return default
try:
num = float(value)
except (TypeError, ValueError):
return value
if not math.isfinite(num):
return value
return int(num) if num.is_integer() else num
def _to_number(value: Any) -> float:
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def _to_int(value: Any) -> int:
return int(_to_number(value))
def _first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def normalize_playlist_id(playlist_id: Any) -> str:
"""歌单链接 → 裸 id;digest 前缀形态原样保留(交给 song_list 分流)。"""
raw = "" if playlist_id is None else str(playlist_id).strip()
if not raw:
return ""
for pattern in _ID_PATTERNS:
match = pattern.search(raw)
if match:
return match.group(1)
return raw
def _to_summary(item: dict[str, Any]) -> dict[str, Any]:
"""歌单摘要:在原始条目上补齐契约要求的 id/name/cover/trackCount/… 字段。"""
playlist_id = str(item.get("id") or "").strip()
plain_id = _DIGEST_PREFIX_RE.sub("", playlist_id)
return {
**item,
"id": playlist_id,
"name": str(item.get("name") or "").strip(),
"cover": _first_non_empty(item.get("img"), item.get("cover")),
# 原实现用 `??` 而非 `||`,total 为 0 时不会退化到 trackCount
"trackCount": _to_int(item["total"] if item.get("total") is not None else item.get("trackCount")) or 0,
"playCount": _js_number_or_raw(item.get("play_count")),
"creator": _first_non_empty(item.get("author"), item.get("creator"), item.get("uname")),
"description": _first_non_empty(item.get("desc"), item.get("description")),
"link": f"http://www.kuwo.cn/playlist_detail/{plain_id}" if plain_id else "",
"source": "kw",
}
def _windowed(result: dict[str, Any], page: int, limit: int) -> dict[str, Any]:
"""推荐/分类接口的 limit 由服务端固定,显式更小时本地截断。"""
requested = _to_int(limit)
server_limit = _to_int(result.get("limit"))
songs = [item for item in (_to_summary(raw) for raw in result.get("list") or []) if item["id"]]
if requested > 0 and len(songs) > requested:
songs = songs[:requested]
effective = min(requested, server_limit) if requested > 0 and server_limit > 0 else server_limit
return {
"source": "kw",
"list": songs,
"page": page,
"limit": effective or len(songs),
"total": _to_int(result.get("total")) or len(songs),
}
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
word = str(keyword or "").strip()
if not word:
return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0}
res = await song_list.search(word, page, limit)
songs = [item for item in (_to_summary(raw) for raw in res.get("list") or []) if item["id"]]
return {
"source": "kw",
"list": songs,
"page": page,
"limit": limit,
"total": _to_int(res.get("total")) or len(songs),
}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
raw_id = normalize_playlist_id(playlist_id)
if not raw_id:
raise RuntimeError("酷我歌单 id 为空")
page_no = max(1, _to_int(page) or 1)
page_size = max(1, _to_int(limit) or 50)
start = (page_no - 1) * page_size
server_page = start // SERVER_PAGE_SIZE + 1
skip = start % SERVER_PAGE_SIZE
songs: list[dict[str, Any]] = []
info: dict[str, Any] | None = None
total = 0
while len(songs) < page_size and server_page <= MAX_SERVER_PAGES:
res = await song_list.get_list_detail(raw_id, server_page)
if not res:
break
if info is None:
info = res.get("info")
total = _to_int(res.get("total")) or total
raw_list = res.get("list") or []
if not raw_list:
break
songs.extend(raw_list[skip : skip + (page_size - len(songs))])
skip = 0
if len(raw_list) < SERVER_PAGE_SIZE:
break
if total > 0 and server_page * SERVER_PAGE_SIZE >= total:
break
server_page += 1
info = info or {}
return {
"source": "kw",
"info": {
"name": _first_non_empty(info.get("name"), raw_id),
"img": _first_non_empty(info.get("img")),
"desc": _first_non_empty(info.get("desc")),
"author": _first_non_empty(info.get("author")),
"play_count": info.get("play_count", ""),
},
"list": songs,
"page": page_no,
"limit": page_size,
"total": total or len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""聚合全部页曲目并按 songmid 去重。"""
raw_id = normalize_playlist_id(playlist_id)
if not raw_id:
raise RuntimeError("酷我歌单 id 为空")
songs: list[dict[str, Any]] = []
seen: set[str] = set()
for server_page in range(1, MAX_SERVER_PAGES + 1):
res = await song_list.get_list_detail(raw_id, server_page)
raw_list = res.get("list") or []
for song in raw_list:
key = str(song.get("songmid") or "")
if key and key in seen:
continue
if key:
seen.add(key)
songs.append(song)
total = _to_int(res.get("total"))
if len(raw_list) < SERVER_PAGE_SIZE:
break
if total > 0 and len(songs) >= total:
break
return songs
async def get_categories() -> dict[str, Any]:
tags_data = await song_list.get_tags()
hot_ids = {str(item.get("id") or "") for item in tags_data.get("hotTag") or []}
result: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
for group in tags_data.get("tags") or []:
for item in group.get("list") or []:
tag_id = str(item.get("id") or "").strip()
name = str(item.get("name") or "").strip()
if not tag_id or not name:
continue
result.append(
{
"id": tag_id,
"name": name,
"group": str(group.get("name") or "").strip() or "其他",
"hot": tag_id in hot_ids,
}
)
return {"source": "kw", "list": result}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
tag_id = str(category_id or "").strip()
page_no = max(1, _to_int(page) or 1)
res = await song_list.get_list("hot", tag_id or None, page_no)
return _windowed(res, page_no, limit)
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
page_no = max(1, _to_int(page) or 1)
res = await song_list.get_list("hot", None, page_no)
return _windowed(res, page_no, limit)
async def parse_playlist_link(link: str) -> dict[str, Any]:
raw = str(link or "")
for pattern in _ID_PATTERNS:
match = pattern.search(raw)
if match:
return await get_playlist_detail(match.group(1), 1)
raise RuntimeError("无效的酷我歌单链接")
__all__ = [
"get_categories",
"get_category_playlists",
"get_playlist_detail",
"get_playlist_songs",
"get_recommended",
"normalize_playlist_id",
"parse_playlist_link",
"search_playlist",
]
+517
View File
@@ -0,0 +1,517 @@
"""酷我歌单广场与歌单详情(对应 sdk/kw/songList.ts)。
四条互不相同的上游链路,按歌单 id 形态分流(`get_list_detail`):
* `digest-<n>__<id>` —— 广场列表返回的内部 id,digest 决定走哪个接口:
13 → 专辑(转交 album.get_album_list_detail)
5 → 旧歌单(先 qukudata 查 sourceid,再用 nplserver 取曲目)
其余 → nplserver 直取
* `/bodian/` 链接 —— BD 接口(bd-api.kuwo.cn),需要 reqId 与 h5 头
* 网页链接 —— 正则抽出裸 id 后按 nplserver 取
nplserver 固定 1000 条/页,因此详情类调用都在调用方(playlist.py)做本地开窗。
"""
from __future__ import annotations
import asyncio
import random
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_count, format_play_time
from app.sdk.platforms.kw.util import format_singer, obj_str2json
LIST_LIMIT = 36
SONG_LIMIT = 1000
SUCCESS_CODE = 200
SORT_LIST: list[dict[str, str]] = [
{"name": "最新", "id": "new"},
{"name": "最热", "id": "hot"},
]
# 路由层以 `song_list.sort_list[0]["id"]` 取默认排序,名字沿用原 TS 的 sortList
sort_list = SORT_LIST
TAGS_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist"
"&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0"
"&loginUid=0&loginSid=0&appUid=76039576"
)
HOT_TAG_API = "http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576"
RCM_LIST_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0"
"&appUid=76039576&&pn={page}&rn={rn}&order={sort}"
)
TAG_LIST_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0"
"&appUid=76039576&pn={page}&id={id}&rn={rn}"
)
QZ_LIST_API = "http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id={id}&prod=pc"
LIST_DETAIL_API = (
"http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}"
"&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1"
)
DIGEST5_INFO_API = "http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node={id}&pn=0&rn=1&fmt=json&src=mbox&level=2"
DIGEST5_MUSIC_API = (
"http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}"
"&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1"
)
BD_INFO_API = "https://bd-api.kuwo.cn/api/service/playlist/info/{id}?reqId={req_id}&source={source}"
BD_USER_API = "https://bd-api.kuwo.cn/api/ucenter/users/pub/{id}?reqId={req_id}"
BD_LIST_API = (
"https://bd-api.kuwo.cn/api/service/playlist/{id}/musicList?reqId={req_id}&source={source}&pn={page}&rn={rn}"
)
SEARCH_API = (
"http://search.kuwo.cn/r.s?all={keyword}&pn={pn}&rn={rn}&rformat=json&encoding=utf8"
"&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0"
)
BD_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
),
"plat": "h5",
}
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
_DETAIL_LINK_RE = re.compile(r"^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)")
_LINKISH_RE = re.compile(r"[?&:/]")
_DIGEST_RE = re.compile(r"^digest-")
_BD_RE = re.compile(r"/bodian/")
_DIGEST_SPLIT_RE = re.compile(r"__")
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _as_str(value: Any) -> str:
return "" if value is None else str(value)
def _get_list_url(sort_id: str, playlist_id: str | None, digest: str, page: int) -> str:
if not playlist_id:
return RCM_LIST_API.format(page=page, rn=LIST_LIMIT, sort=sort_id)
if digest == "10000":
return TAG_LIST_API.format(page=page, id=playlist_id, rn=LIST_LIMIT)
if digest == "43":
return QZ_LIST_API.format(id=playlist_id)
# 原文此处无 return,httpFetch(undefined) 会直接抛错;这里显式抛同样的异常语义
raise RuntimeError(f"不支持的酷我歌单标签类型: {digest}")
def _filter_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, str]] = []
_types: dict[str, dict[str, str]] = {}
for entry in str(item.get("N_MINFO") or "").split(";"):
match = _MININFO_RE.search(entry)
if match is None:
continue
quality = _BITRATE_QUALITY.get(match.group(2))
if quality is None:
continue
types.append({"type": quality, "size": match.group(4)})
_types[quality] = {"size": match.group(4).upper()}
types.reverse()
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": decode_name(item.get("album")),
"albumId": item.get("albumid"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": None,
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _filter_list(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"play_count": format_play_count(_to_int(item.get("listencnt"))),
"id": f"digest-{item.get('digest')}__{item.get('id')}",
"author": item.get("uname"),
"name": item.get("name"),
"total": item.get("total"),
"img": item.get("img"),
"grade": _to_int(item.get("favorcnt")) / 10,
"desc": item.get("desc"),
"source": "kw",
}
for item in raw_list
]
def _filter_list2(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""qz_data 接口返回按 label 分组的二维结构,逐组展开成一维。
原实现用 `a && f(b)` 做兜底:条件为真才格式化,否则原样返回条件值。
play_count 用 `item.play_count` 作条件却格式化 `item.listencnt`,逐个照搬。
"""
result: list[dict[str, Any]] = []
for group in raw_data:
if not group.get("label"):
continue
for item in group.get("list") or []:
raw_play = item.get("play_count")
raw_favor = item.get("favorcnt")
result.append(
{
"play_count": format_play_count(_to_int(item.get("listencnt"))) if raw_play else (raw_play or 0),
"id": f"digest-{item.get('digest')}__{item.get('id')}",
"author": item.get("uname"),
"name": item.get("name"),
"total": item.get("total"),
"img": item.get("img"),
"grade": _to_int(raw_favor) / 10 if raw_favor else (raw_favor or 0),
"desc": item.get("desc"),
"source": "kw",
}
)
return result
def _detail_info(body: dict[str, Any]) -> dict[str, Any]:
return {
"name": body.get("title"),
"img": body.get("pic"),
"desc": body.get("info"),
"author": body.get("uname"),
"play_count": format_play_count(_to_int(body.get("playnum"))),
}
async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(TAGS_API)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(retry_num + 1)
return [
{
"name": group.get("name"),
"list": [
{
"parent_id": group.get("id"),
"parent_name": group.get("name"),
"id": f"{item.get('id')}-{item.get('digest')}",
"name": item.get("name"),
"source": "kw",
}
for item in group.get("data") or []
],
}
for group in body.get("data") or []
]
async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_TAG_API)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_hot_tag(retry_num + 1)
groups = body.get("data") or [{}]
return [
{"id": f"{item.get('id')}-{item.get('digest')}", "name": item.get("name"), "source": "kw"}
for item in (groups[0] or {}).get("data") or []
]
async def get_tags() -> dict[str, Any]:
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag())
return {"tags": tags, "hotTag": hot_tag, "source": "kw"}
async def get_list(sort_id: str, tag_id: str | None = None, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
playlist_id: str | None = None
digest = ""
if tag_id:
parts = tag_id.split("-")
playlist_id = parts[0]
digest = parts[1] if len(parts) > 1 else ""
url = _get_list_url(sort_id, playlist_id, digest, page)
resp = await http_fetch(url)
body = resp.body
if not playlist_id or digest == "10000":
if not isinstance(body, dict) or body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, retry_num + 1)
data = body.get("data") or {}
return {
"list": _filter_list(data.get("data") or []),
"total": data.get("total"),
"page": data.get("pn"),
"limit": data.get("rn"),
"source": "kw",
}
if not isinstance(body, list) or not body:
return await get_list(sort_id, tag_id, page, retry_num + 1)
return {"list": _filter_list2(body), "total": SONG_LIMIT, "page": page, "limit": SONG_LIMIT, "source": "kw"}
async def get_list_detail_digest8(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(LIST_DETAIL_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("result") != "ok":
return await get_list_detail(playlist_id, page, retry_num + 1)
return {
"list": _filter_list_detail(body.get("musiclist") or []),
"page": page,
"limit": body.get("rn"),
"total": body.get("total"),
"source": "kw",
"info": _detail_info(body),
}
async def get_list_detail_digest5_info(playlist_id: str, retry_num: int = 0) -> str | None:
"""digest=5 的歌单 id 不能直接给 nplserver,要先查出真正的 sourceid。"""
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(DIGEST5_INFO_API.format(id=playlist_id))
body = resp.body if isinstance(resp.body, dict) else {}
children = body.get("child")
if resp.status_code != 200 or not children:
return await get_list_detail_digest5_info(playlist_id, retry_num + 1)
return str(children[0].get("sourceid")) if children else None
async def get_list_detail_digest5_music(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(DIGEST5_MUSIC_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("result") != "ok":
return await get_list_detail(playlist_id, page, retry_num + 1)
return {
"list": _filter_list_detail(body.get("musiclist") or []),
"page": page,
"limit": body.get("rn"),
"total": body.get("total"),
"source": "kw",
"info": _detail_info(body),
}
async def get_list_detail_digest5(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
detail_id = await get_list_detail_digest5_info(playlist_id, retry_num)
if detail_id is None:
raise RuntimeError("failed")
return await get_list_detail_digest5_music(detail_id, page, retry_num)
def _filter_bd_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for audio in item.get("audios") or []:
size = _as_str(audio.get("size")).upper() if audio.get("size") is not None else None
quality = _BITRATE_QUALITY.get(_as_str(audio.get("bitrate")))
if quality is None:
continue
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
types.reverse()
result.append(
{
"singer": "、".join(_as_str(s.get("name")) for s in item.get("artists") or []),
"name": item.get("name"),
"albumName": item.get("album"),
"albumId": item.get("albumId"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": item.get("albumPic"),
"releaseDate": item.get("releaseDate"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _get_req_id() -> str:
"""BD 接口的 reqId:八段随机 16 进制。
原实现 `(65536 * (1 + Math.random()) | 0).toString(16).substring(1)`:
值落在 [0x10000, 0x1ffff],固定 5 位 16 进制,去掉首位后取 4 位。
"""
def segment() -> str:
return f"{int(65536 * (1 + random.random())):05x}"[1:]
return "".join(segment() for _ in range(8))
async def _bd_playlist_info(playlist_id: str, source: str) -> dict[str, Any] | None:
resp = await http_fetch(BD_INFO_API.format(id=playlist_id, req_id=_get_req_id(), source=source), headers=BD_HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
return None
data = body.get("data") or {}
return {
"name": data.get("name"),
"img": data.get("pic"),
"desc": data.get("description"),
"author": data.get("creatorName"),
"play_count": data.get("playNum"),
}
async def _bd_user_pub_info(user_id: str) -> dict[str, Any] | None:
resp = await http_fetch(BD_USER_API.format(id=user_id, req_id=_get_req_id()), headers=BD_HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
return None
info = (body.get("data") or {}).get("userInfo") or {}
nickname = _as_str(info.get("nickname"))
return {
"name": nickname + "喜欢的音乐",
"img": info.get("headImg"),
"desc": "",
"author": nickname,
"play_count": "",
}
async def _bd_playlist_songs(playlist_id: str, source: str, page: int, retry_num: int = 0) -> dict[str, Any]:
try:
resp = await http_fetch(
BD_LIST_API.format(id=playlist_id, req_id=_get_req_id(), source=source, page=page, rn=SONG_LIMIT),
headers=BD_HEADERS,
)
except Exception as err:
if retry_num > 2:
raise RuntimeError("try max num") from err
return await _bd_playlist_songs(playlist_id, source, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
raise RuntimeError("failed")
data = body.get("data") or {}
return {
"list": _filter_bd_list_detail(data.get("list") or []),
"page": page,
"limit": data.get("pageSize"),
"total": data.get("total"),
"source": "kw",
}
def _match_group(link: str, pattern: str) -> str | None:
found = re.search(pattern, link)
return found.group(1) if found else None
async def _get_list_detail_by_bd(raw_id: str, page: int) -> dict[str, Any]:
"""bodian 分享链接:uid/playlistId/source 都在 query 里,缺 source 时按原实现拼字面量。"""
uid = _match_group(raw_id, r"uid=(\d+)")
playlist_id = _match_group(raw_id, r"playlistId=(\d+)")
source = _match_group(raw_id, r"source=(\d+)") or "undefined"
if not playlist_id:
raise RuntimeError("failed")
tasks: list[Any] = [_bd_playlist_songs(playlist_id, source, page)]
if source == "4":
tasks.append(_bd_playlist_info(playlist_id, source))
elif source == "5":
tasks.append(_bd_user_pub_info(uid or playlist_id))
results = await asyncio.gather(*tasks)
list_data = results[0]
info = results[1] if len(results) > 1 else None
list_data["info"] = info or {"name": "", "img": "", "desc": "", "author": "", "play_count": ""}
return list_data
async def get_list_detail(playlist_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if _BD_RE.search(playlist_id):
return await _get_list_detail_by_bd(playlist_id, page)
if _LINKISH_RE.search(playlist_id):
playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id)
elif _DIGEST_RE.search(playlist_id):
parts = _DIGEST_SPLIT_RE.split(playlist_id)
digest = parts[0].replace("digest-", "")
playlist_id = parts[1] if len(parts) > 1 else ""
if digest == "13":
from app.sdk.platforms.kw import album
return await album.get_album_list_detail(playlist_id, page, retry_num)
# 原文 switch 里只有 '8' 会 break 落到 digest8,其余(含 '5' 与未知 digest)全走 digest5
if digest == "8":
return await get_list_detail_digest8(playlist_id, page, retry_num)
return await get_list_detail_digest5(playlist_id, page, retry_num)
return await get_list_detail_digest8(playlist_id, page, retry_num)
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
resp = await http_fetch(SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=limit))
body = obj_str2json(resp.raw.decode("utf-8", errors="replace"))
return {
"list": [
{
"play_count": format_play_count(_to_int(item.get("playcnt"))),
"id": _as_str(item.get("playlistid")),
"author": decode_name(item.get("nickname")),
"name": decode_name(item.get("name")),
"total": item.get("songnum"),
"img": item.get("pic"),
"desc": decode_name(item.get("intro")),
"source": "kw",
}
for item in body.get("abslist") or []
],
"limit": limit,
"total": _to_int(body.get("TOTAL")),
"source": "kw",
}
def get_detail_page_url(playlist_id: str) -> str:
if _LINKISH_RE.search(playlist_id):
playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id)
elif _DIGEST_RE.search(playlist_id):
parts = playlist_id.split("__")
playlist_id = parts[1] if len(parts) > 1 else ""
return f"http://www.kuwo.cn/playlist_detail/{playlist_id}"
__all__ = [
"SORT_LIST",
"get_detail_page_url",
"get_hot_tag",
"get_list",
"get_list_detail",
"get_tag",
"get_tags",
"search",
]
+192
View File
@@ -0,0 +1,192 @@
"""酷我平台工具(对应 sdk/kw/util.ts)。
四块内容:legacy 接口的 JS 对象字面量转 JSON、歌手名格式化、逐字歌词(lrcx)解析、
wbd 榜单接口的 AES-128-ECB 加解密与签名。
"""
from __future__ import annotations
import base64
import json
import math
import random
import re
import time
from typing import Any
from urllib.parse import quote, unquote
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import to_md5
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
MOBI_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 "
"(KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36"
)
HOME_URL = "https://www.kuwo.cn/"
# 单引号替换:原文是一条含变长后顾断言的正则,Python re 不支持变长 lookbehind,
# 因此拆成「引号前是 {,: 」与「引号后是 },: 」两步,覆盖范围与原文等价。
_QUOTE_START = re.compile(r"(?P<pre>[{:,]\s*)'")
_QUOTE_END = re.compile(r"'(?P<post>\s*[}:,])")
def obj_str2json(text: str) -> Any:
"""legacy r.s 返回的是 JS 对象字面量(单引号 + 未加引号的形态),先转成合法 JSON。
专辑详情实测确认仍是这一形态,不能直接 json.loads。
"""
fixed = _QUOTE_START.sub(r'\g<pre>"', text)
fixed = _QUOTE_END.sub(r'"\g<post>', fixed)
return json.loads(fixed)
def format_singer(raw: str) -> str:
"""酷我用 `&` 分隔合唱歌手,统一换成顿号。"""
return raw.replace("&", "、")
# ---------- 逐字歌词 ----------
_WORD_LINE = re.compile(r"^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*")
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
_WORD_TIME = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
_LINE_SPLIT = re.compile(r"\r\n|\r|\n")
def _js_number(value: float) -> str:
"""JS 的 Number → String:整数值不带小数点,其余取最短往返表示(与 repr 同)。"""
return str(int(value)) if float(value).is_integer() else repr(float(value))
class LrcTools:
"""lrcx 逐字歌词解析器。原实现靠 Object.create 做每轮隔离,这里用新实例。"""
def __init__(self) -> None:
self.offset = 1
self.offset2 = 1
self.is_ok = True
self.lines: list[str] = []
self.tags: list[str] = []
def get_word_info(self, first: str, second: str, prev: dict[str, Any] | None) -> dict[str, Any]:
offset = int(first)
offset2 = int(second)
start = abs((offset + offset2) / (self.offset * 2))
end = abs((offset - offset2) / (self.offset2 * 2)) + start
if prev is not None and start < prev["end"]:
# 上一句被本句起点截断,需要回头改写已拼进 words 的那一段
prev["end"] = start
if prev["start"] > prev["end"]:
prev["start"] = prev["end"]
prev["new"] = f"<{_js_number(prev['start'])},{_js_number(prev['end'] - prev['start'])}>"
return {"start": start, "end": end, "text": f"<{_js_number(start)},{_js_number(end - start)}>"}
def parse_line(self, line: str) -> None:
if len(line) < 6:
return
match = _WORD_LINE.match(line)
if match:
head = match.group(1)
words = match.group(2) or ""
spans = [item.group(0) for item in _WORD_TIME.finditer(words)]
if not spans:
return
prev: dict[str, Any] | None = None
for span in spans:
found = _WORD_TIME.search(span)
if found is None:
continue
info = self.get_word_info(found.group(1), found.group(2), prev)
words = words.replace(span, info["text"], 1)
if prev is not None and prev.get("new"):
words = words.replace(prev["text"], prev["new"], 1)
prev = info
self.lines.append(head + words)
return
tag = _TAG_LINE.search(line)
if tag is None:
return
if tag.group(1) != "kuwo":
self.tags.append(line)
return
content = tag.group(2)
if "][" in content:
content = content[: content.index("][")]
try:
value = int(content.strip(), 8)
except ValueError:
self.is_ok = False
return
self.offset = int(value / 10)
self.offset2 = value % 10
# 原文对 NaN/0 一律判失败,等价于这里两个偏移都非零
if self.offset == 0 or self.offset2 == 0:
self.is_ok = False
def parse_yrc(lrc: str) -> str:
"""把歌词里的 lrcx 时间标记规范化;失败抛 RuntimeError(原文文案 failed)。"""
tools = LrcTools()
for line in _LINE_SPLIT.split(lrc):
if not tools.is_ok:
raise RuntimeError("failed")
tools.parse_line(line)
if not tools.lines:
return ""
merged = "\n".join(tools.lines)
if tools.tags:
merged = "\n".join(tools.tags) + "\n" + merged
return merged
# ---------- wbd 榜单接口加解密 ----------
WBD_APP_ID = "y67sprxhhpws"
_WBD_KEY = bytes([112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228])
def _aes_ecb(data: bytes, *, encrypt: bool) -> bytes:
cipher = Cipher(algorithms.AES(_WBD_KEY), modes.ECB())
if encrypt:
pad = 16 - len(data) % 16
padded = data + bytes([pad]) * pad
context = cipher.encryptor()
return context.update(padded) + context.finalize()
context = cipher.decryptor()
out = context.update(data) + context.finalize()
pad = out[-1] if out else 0
return out[:-pad] if 0 < pad <= 16 else out
def wbd_decode_data(payload: str) -> Any:
"""解码 wbd 响应:URL 编码的 base64 → AES-128-ECB 解密 → JSON。"""
raw = base64.b64decode(unquote(payload))
return json.loads(_aes_ecb(raw, encrypt=False).decode())
def wbd_build_param(json_data: Any) -> str:
"""构造 wbd 请求 query:加密体 + 毫秒时间戳 + appId + MD5 大写签名。"""
data = json.dumps(json_data, ensure_ascii=False, separators=(",", ":")).encode()
time_ms = int(time.time() * 1000)
encoded = base64.b64encode(_aes_ecb(data, encrypt=True)).decode()
sign = to_md5(f"{WBD_APP_ID}{encoded}{time_ms}").upper()
return f"data={quote(encoded, safe='')}&time={time_ms}&appId={WBD_APP_ID}&sign={sign}"
async def fetch_home_set_cookie() -> list[str]:
"""取酷我首页下发的全部 Set-Cookie。
用 http_fetch 的 set_cookies(原始多值头),而不是折叠后的 headers dict——
Secret 签名必须认出动态下发的 Hm_Iuvt_* cookie(名字与值均随机),
同名头被折叠会恰好丢掉目标 cookie。
"""
resp = await http_fetch(HOME_URL, headers={"User-Agent": UA}, timeout=15_000)
return list(resp.set_cookies)
def random_digits() -> int:
"""JS 的 Math.round(1e9 * Math.random()) —— round 是四舍五入,不是 Python 的银行家舍入。"""
return math.floor(1e9 * random.random() + 0.5)
+80
View File
@@ -0,0 +1,80 @@
"""懒人听书(lrts.me)平台模块(对应 sdk/lr/index.ts)。
匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
章节 songmid 为复合 id ``<bookId>:<section>``;有声内容不参与聚合(noAggregate)。
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.lr import menu, music_search
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.lr import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(_song_info: dict[str, Any]) -> None:
"""书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回 None 由调用方回退)。"""
return None
async def get_album_detail(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
result = await menu.get_book_menu(book_id, page, limit)
total = result["total"]
songs = []
for item in result["list"][:limit]:
if not isinstance(item, dict):
continue
section = item.get("section")
songs.append(
{
"songmid": f"{book_id}:{section}",
"name": item.get("name") or f"第{section}节",
"singer": "",
"albumId": str(book_id),
"albumName": "",
"interval": 0,
"payPlay": str(item.get("listenPrice")) != "0",
"img": None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"info": {"name": f"书籍 {book_id}", "total": total},
"list": songs,
"total": total,
"limit": limit,
"allPage": math.ceil(total / 30) or page,
"source": "lr",
}
class _LrAlbum:
get_album_detail = staticmethod(get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
book_id = str(song_info.get("songmid") or "").split(":")[0]
return f"https://m.lrts.me/book/{book_id}"
MODULE = Platform(
id="lr",
name="懒人听书",
music_search=music_search,
album=_LrAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+47
View File
@@ -0,0 +1,47 @@
"""懒人听书 App API(对应 sdk/lr/appApi.ts,dapis.mting.info)—— 搜索通道(游客 token 匿名可用)。
签名(移植 loyalflower/lrts_download download.go):
sc = md5(urlPath + "?" + <按 key 排序的 query(不含 sc) 拼接> + secret),secret = "iJ0DgxmdC83#I&j@iwg"
头:UA Android13/yyting/... / Referer yytingting.com / ClientVersion 6.3.4.0
端点:/yyting/search/searchBatch.action(q=2150)
注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.py)。
"""
from __future__ import annotations
import hashlib
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
DOMAIN = "http://dapis.mting.info"
SECRET = "iJ0DgxmdC83#I&j@iwg"
GUEST_TOKEN = "OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU"
GUEST_IMEI = "MDI6MDA6MDA6MDA6MDA6DDA="
APP_HEADERS = {
"User-Agent": "Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android",
"Referer": "yytingting.com",
"ClientVersion": "6.3.4.0",
}
def encode_uri_component(value: Any) -> str:
return quote(str(value), safe="!~*'()")
def signed(path: str, params: dict[str, str]) -> str:
query: dict[str, str] = {"token": GUEST_TOKEN, "imei": GUEST_IMEI, "nwt": "1", **params}
qs = "&".join(f"{key}={query[key]}" for key in sorted(query))
query["sc"] = hashlib.md5(f"{path}?{qs}{SECRET}".encode()).hexdigest()
return f"{DOMAIN}{path}?" + "&".join(f"{key}={encode_uri_component(value)}" for key, value in query.items())
async def app_api_get(path: str, params: dict[str, str]) -> dict[str, Any]:
"""App API GET(游客态,仅搜索可用;章节/取流会 482 需登录)。"""
resp = await http_fetch(signed(path, params), headers=APP_HEADERS, timeout=15_000)
if resp.status_code != 200:
raise RuntimeError(f"懒人 App API HTTP {resp.status_code}")
return resp.body if isinstance(resp.body, dict) else {}
+33
View File
@@ -0,0 +1,33 @@
"""懒人听书章节列表(对应 sdk/lr/menu.ts,web 通道,匿名免签)。
接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list
UA_MI = (
"Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0"
)
MENU_URL = "https://m.lrts.me/ajax/getBookMenu"
async def get_book_menu(book_id: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
resp = await http_fetch(
f"{MENU_URL}?bookId={book_id}&pageNum={page}&pageSize={max(30, limit)}&sortType=0",
headers={"User-Agent": UA_MI, "Referer": "https://m.lrts.me/"},
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError(f"获取章节失败(HTTP {resp.status_code})")
body = as_dict(resp.body)
if body.get("status") == 1 or not body.get("list"):
raise RuntimeError(str(body.get("msg") or "获取章节失败"))
return {"total": int(body.get("sections") or 0), "list": as_list(body.get("list"))}
+58
View File
@@ -0,0 +1,58 @@
"""懒人听书搜索(对应 sdk/lr/musicSearch.ts,App 通道,游客匿名)。
接口: /yyting/search/searchBatch.action?keyWord=&searchOption=1&pageSize=&type=0&q=2150
实测(2026-10-02)匿名可用;结果分 data.bookResult/albumResult/folderResult/...
书籍条目字段:id(baseEntityId 同值)/name/announcer/cover
"""
from __future__ import annotations
import math
from typing import Any
from app.sdk.common.utils import as_dict, as_list
from app.sdk.platforms.lr.app_api import app_api_get
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,游客搜索无重试语义
body = await app_api_get(
"/yyting/search/searchBatch.action",
{
"keyWord": keyword,
"searchOption": "1",
"pageSize": str(limit),
"type": str(page - 1),
"q": "2150",
},
)
api_status = body.get("apiStatus")
if api_status is not None and api_status != 0:
raise RuntimeError(str(body.get("msg") or f"搜索失败(code {api_status})"))
book_result = as_dict(as_dict(body.get("data")).get("bookResult"))
books = [item for item in as_list(book_result.get("list")) if isinstance(item, dict)]
total = int(book_result.get("count") or len(books))
return {
"allPage": math.ceil(total / limit) or page,
"total": total,
"limit": limit,
"source": "lr",
"list": [
{
"songmid": str(item.get("id", item.get("baseEntityId"))),
"name": item.get("name") or "",
"singer": item.get("announcer") or item.get("author") or "",
"albumId": "",
"albumName": "",
"interval": 0,
"payPlay": False,
"img": item.get("cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
for item in books
],
}
+55
View File
@@ -0,0 +1,55 @@
"""猫耳FM(MissEvan,ACG 广播剧/音声平台)平台模块(对应 sdk/me/index.ts)。
匿名可用面(2026-10-02 实测):搜索 / 剧集列表 / 免费声音明文直链 / 封面。
取流走 App API 签名通道(app.missevan.com,HMAC-SHA256(appsecret)),
返回 maoercdn 免签名直链——网页版 hls.m3u8 是 Widevine 加密流,勿用 web soundurl。
付费声音需 POST /login/me 绑定含 token 的 cookie。
有声内容不参与聚合搜索与跨平台匹配(noAggregate),仅按 source=me 直查。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_credentials
from app.sdk.platforms._base import Platform
from app.sdk.platforms.me import album, music_search
from app.sdk.platforms.me.music_info import get_sound_info
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.me import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(song_info: dict[str, Any]) -> str | None:
info = await get_sound_info(str(song_info.get("songmid") or ""))
return info.get("front_cover") or None
class _MeAlbum:
get_album_detail = staticmethod(album.get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"https://www.missevan.com/sound/{song_info.get('songmid') or ''}"
def status() -> dict[str, Any]:
"""登录状态(凭据存在性判断)。"""
cred = get_credentials("me") or {}
if cred.get("cookie"):
return {"logged": True, "updatedAt": cred.get("updatedAt")}
return {"logged": False}
MODULE = Platform(
id="me",
name="猫耳FM",
music_search=music_search,
album=_MeAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+73
View File
@@ -0,0 +1,73 @@
"""猫耳FM 广播剧集列表(对应 sdk/me/album.ts,web 通道,匿名免签)。
接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize=
结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name。
注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法),
本接口支持翻页故用之。
"""
from __future__ import annotations
import math
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
EPISODES_URL = "https://www.missevan.com/dramaapi/getdramaepisodes"
async def get_album_detail(drama_id_raw: str, page: int = 1, limit: int = 30) -> dict[str, Any]:
# 搜索结果的剧条目带 d: 前缀(songmid="d:<id>"),这里剥掉
drama_id = drama_id_raw[2:] if str(drama_id_raw).startswith("d:") else str(drama_id_raw)
if not re.fullmatch(r"\d+", drama_id):
raise RuntimeError("剧 id 无效")
resp = await http_fetch(
f"{EPISODES_URL}?drama_id={drama_id}&season=1&page={page}&order=0&pageSize={max(30, limit)}",
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
),
"Referer": f"https://www.missevan.com/mdrama/drama/{drama_id}",
"Accept": "application/json",
},
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("success") is False:
raise RuntimeError(str(body.get("info") or f"获取剧集失败(HTTP {resp.status_code})"))
info = as_dict(body.get("info"))
drama = as_dict(info.get("drama"))
episodes = as_list(as_dict(info.get("episodes")).get("episode"))
songs = []
for item in episodes[:limit]:
if not isinstance(item, dict):
continue
duration = item.get("duration")
songs.append(
{
"songmid": str(item.get("sound_id")),
"name": item.get("name") or "",
"singer": drama.get("name") or "",
"albumId": str(drama_id),
"albumName": drama.get("name") or "",
"interval": format_play_time(math.floor(float(duration) / 1000 + 0.5)) if duration else 0,
"payPlay": int(item.get("pay_type") or 0) > 0,
"img": drama.get("cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {
"info": {"name": drama.get("name") or str(drama_id), "total": len(episodes)},
"list": songs,
"total": len(episodes),
"limit": limit,
"allPage": 1,
"source": "me",
}
+18
View File
@@ -0,0 +1,18 @@
"""猫耳FM 曲目信息(对应 sdk/me/musicInfo.ts,App API 通道)。
接口: GET https://app.missevan.com/sound/sound?sound_id=
返回 info 直接是 sound 对象(非 web 版的 info.sound 包装):
soundstr/duration(毫秒)/front_cover/soundurl(原文件)/soundurl_64(=原文件)/
soundurl_128/-192k 变体,均为 maoercdn.com 免签名明文直链。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.me.sign import app_api_get
async def get_sound_info(sound_id: str) -> dict[str, Any]:
info = await app_api_get("/sound/sound", f"sound_id={sound_id}")
return info if isinstance(info, dict) else {}
+111
View File
@@ -0,0 +1,111 @@
"""猫耳FM 搜索(对应 sdk/me/musicSearch.ts,双通道聚合,与网页版对齐)。
广播剧(置顶): https://www.missevan.com/dramaapi/search?s=&page= → info.Datas[]
——网页版搜索排前面的正剧来自这里;getsearch 的 type 参数只有 1UP主/2音单/3声音/4声优,没有剧集类型
声音(补足): https://www.missevan.com/sound/getsearch?s=&p=&type=3&page_size=&sort=2 → info.Datas[]
剧条目 songmid 用 "d:<drama_id>" 前缀(取流时自动取首个免费集,见 apis/me.py);声音条目为裸 sound_id。
"""
from __future__ import annotations
import asyncio
import math
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict, as_list, format_play_time
from app.sdk.platforms.fivesing.util import encode_uri_component
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
H = {"User-Agent": UA, "Accept": "application/json", "Referer": "https://www.missevan.com/sound/search"}
async def _search_dramas(keyword: str, page: int, limit: int) -> list[dict[str, Any]]:
resp = await http_fetch(
f"https://www.missevan.com/dramaapi/search?s={encode_uri_component(keyword)}&page={page}",
headers=H,
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("success") is False:
return []
datas = as_list(as_dict(body.get("info")).get("Datas"))
return [
{
"songmid": f"d:{item.get('id')}",
"name": item.get("name") or "",
"singer": item.get("author") or "",
"albumId": str(item.get("id")),
"albumName": item.get("name") or "",
"interval": 0,
"payPlay": True,
"img": item.get("cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
for item in datas[:limit]
if isinstance(item, dict)
]
async def _search_sounds(keyword: str, page: int, limit: int) -> list[dict[str, Any]]:
resp = await http_fetch(
f"https://www.missevan.com/sound/getsearch?s={encode_uri_component(keyword)}&p={page}&type=3&page_size={limit}&sort=2",
headers=H,
timeout=15_000,
)
body = as_dict(resp.body)
if resp.status_code != 200 or body.get("success") is False:
return []
datas = as_list(as_dict(body.get("info")).get("Datas"))
songs = []
for item in datas:
if not isinstance(item, dict):
continue
duration = item.get("duration")
songs.append(
{
"songmid": str(item.get("id")),
"name": item.get("soundstr") or "",
"singer": item.get("username") or "",
"albumId": "",
"albumName": "",
"interval": format_play_time(math.floor(float(duration) / 1000 + 0.5)) if duration else 0,
"payPlay": int(item.get("pay_type") or 0) > 0,
"img": item.get("front_cover") or None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return songs
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
"""剧优先置顶(约六成配额),零散声音补足;剧集翻页直接传 page。"""
del retry_num # 契约参数,web 搜索无重试语义
async def safe(coro: Any) -> list[dict[str, Any]]:
try:
return await coro
except Exception: # noqa: BLE001 - 单通道失败不拖垮搜索
return []
dramas, sounds = await asyncio.gather(
safe(_search_dramas(keyword, page, max(4, int(limit * 0.6)))),
safe(_search_sounds(keyword, page, limit)),
)
songs = [*dramas, *sounds][:limit]
return {
"allPage": page + (1 if len(songs) >= limit else 0),
"total": len(dramas) + len(sounds),
"limit": limit,
"source": "me",
"list": songs,
}
+98
View File
@@ -0,0 +1,98 @@
"""猫耳FM(MissEvan)App API 签名(对应 sdk/me/sign.ts)—— app.missevan.com 域鉴权。
算法(知乎 p/359692208 + zunley/gofm pkg/missevan/utils.go,2026-10-02 实测可用):
authorization = "MissEvan " + base64(HMAC-SHA256(appsecret, DATA))
DATA = METHOD\\n + url(冒号转义%3A)\\n + querystring\\n + equip_id:<id>\\n [+ token:<t>\\n]
+ x-m-date:<UTC毫秒ISO>\\n + x-m-nonce:<uuid>\\n [+ base64(sha256(body))\\n]
头:User-Agent MissEvanApp、x-m-date、x-m-nonce、Cookie(equip_id[+token])
该通道返回的是免签名 CDN 明文直链(sound-ali*-cdn-cn.maoercdn.com,m4a 原文件/-128k 变体);
网页版 hls.m3u8 为 Widevine 加密,与此无关。
"""
from __future__ import annotations
import base64
import hashlib
import hmac
import re
import uuid
from datetime import UTC, datetime
from typing import Any
from app.sdk.auth_store import get_credentials
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import as_dict
APP_SECRET = "f8Yw1M3u0e5MV7z34PcbY9wgP56YwJ"
APP_UA = "MissEvanApp/4.7.4 (iOS;15.1;iPhone11,6)"
EQUIP_ID = "52e76e9a-7592-43af-ae72-8ec755094e41"
def _utc_millis() -> str:
"""``new Date().toISOString().replace(/\\.\\d+Z$/, '.000Z')`` 的等价实现。"""
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z")
def build_auth(
method: str,
url: str,
query: str,
*,
equip_id: str,
token: str,
date: str,
nonce: str,
) -> str:
"""构造 authorization 签名(GET 便捷封装;POST 需补 body 摘要行)。"""
lines = [
method,
url.replace(":", "%3A"),
query,
f"equip_id:{equip_id}",
]
if token:
lines.append(f"token:{token}")
lines.extend([f"x-m-date:{date}", f"x-m-nonce:{nonce}"])
digest = hmac.new(APP_SECRET.encode(), ("\n".join(lines) + "\n").encode(), hashlib.sha256).digest()
return "MissEvan " + base64.b64encode(digest).decode()
def get_me_token() -> str:
"""登录 token(/login/me 绑定的 cookie 中提取;未登录为空)。"""
cookie = str((get_credentials("me") or {}).get("cookie") or "")
match = re.search(r"(?:^|;\s*)token=([^;\s]+)", cookie)
return match.group(1) if match else ""
async def app_api_get(path: str, query: str = "") -> Any:
"""App API GET 请求(自动携带登录态 token)。"""
full = f"https://app.missevan.com{path}"
date = _utc_millis()
nonce = str(uuid.uuid4())
token = get_me_token()
cookie = f"equip_id={EQUIP_ID}" + (f"; token={token}" if token else "")
resp = await http_fetch(
f"{full}?{query}" if query else full,
headers={
"User-Agent": APP_UA,
"Accept": "application/json",
"Accept-Language": "zh-Hans-CN;q=1",
"x-m-date": date,
"x-m-nonce": nonce,
"Cookie": cookie,
"Authorization": build_auth("GET", full, query, equip_id=EQUIP_ID, token=token, date=date, nonce=nonce),
},
timeout=15_000,
)
if resp.status_code != 200:
raise RuntimeError(f"猫耳 App API HTTP {resp.status_code}")
body = as_dict(resp.body)
code = body.get("code")
if body.get("success") is False or (code is not None and code != 0):
raise RuntimeError(str(body.get("info") or f"猫耳 App API code {code}"))
info = body.get("info")
return info if info is not None else body
ME_UA = APP_UA
+80
View File
@@ -0,0 +1,80 @@
"""咪咕音乐(对应 sdk/mg/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 专辑 / 歌单 / 链接解析,顶层挂 get_music_url(取流在 apis/mg.py)、
get_lyric、get_pic、get_music_info、get_music_detail_page_url。
TS 侧没有榜单、歌单广场、热搜、歌手、评论、个人歌单子模块,这里一律省略 ——
缺省即不支持,/sources 与路由据此返回 404。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.mg import album, lyric, music_search, parse, playlist
from app.sdk.platforms.mg.lyric import content_id_of
from app.sdk.platforms.mg.util import (
fetch_song_detail,
first_non_empty,
migu_song_link,
normalize_migu_image_url,
pick_migu_image,
)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口。延迟导入 apis.mg:apis 模块反向依赖 platforms.mg.util,
模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块。"""
from app.sdk.apis import mg as mg_api
return await mg_api.get_music_url(song_info, quality, retry_num)
async def get_pic(song_info: dict[str, Any]) -> str | None:
if song_info.get("img"):
return str(song_info["img"])
content_id = content_id_of(song_info)
if not content_id:
return None
item = await fetch_song_detail(content_id)
return (
normalize_migu_image_url(
first_non_empty(
pick_migu_image(item.get("imgItems")),
pick_migu_image(item.get("albumImgs")),
item.get("img1"),
item.get("img2"),
item.get("img3"),
)
)
or None
)
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
content_id = content_id_of(song_info)
if not content_id:
raise RuntimeError("migu 歌曲 id 无效")
return await fetch_song_detail(content_id)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
extra = song_info.get("extra") or {}
link = str(extra.get("link") or "") if isinstance(extra, dict) else ""
return link or migu_song_link(content_id_of(song_info))
MODULE = Platform(
id="mg",
name="咪咕音乐",
music_search=music_search,
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,
)
+164
View File
@@ -0,0 +1,164 @@
"""咪咕专辑(对应 sdk/mg/album.ts)。
搜索(search_all.do 的 album 开关)+ 曲目(queryAlbumSong,分页聚合去重)+
详情(resourceinfo 2003 的元数据)。上游对应 music-lib/migu/album.go、
migu.go 的 fetchAlbumDetail / fetchAlbumSongs。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.mg.music_search import search_all
from app.sdk.platforms.mg.util import (
build_url,
convert_item_to_song,
first_non_empty,
migu_album_link,
migu_get,
pick_migu_image,
to_number,
)
QUERY_ALBUM_SONG_URL = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong"
RESOURCE_INFO_URL = "https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do"
_ALBUM_PATTERNS = (
re.compile(r"music\.migu\.cn/(?:v3|v5)/music/album/(\d+)"),
re.compile(r"albumId=(\d+)"),
re.compile(r"resourceId=(\d+)"),
)
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
body = await search_all(keyword, "album", page, limit)
data = body.get("albumResultData") if isinstance(body, dict) else None
result = (data or {}).get("result") or []
album_list: list[dict[str, Any]] = []
for item in result:
album_id = str(item.get("id") or "").strip()
if not album_id:
continue
album_list.append(
{
"id": album_id,
"name": str(item.get("name") or "").strip(),
"cover": pick_migu_image(item.get("imgItems")),
"creator": str(item.get("singer") or "").strip(),
"description": first_non_empty(item.get("desc"), item.get("publishDate")),
"link": migu_album_link(album_id),
"source": "mg",
}
)
return {"list": album_list, "source": "mg", "page": page, "limit": limit, "total": len(album_list)}
async def fetch_album_songs(album_id: str) -> dict[str, Any]:
"""专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs)。"""
album = str(album_id or "").strip()
if not album:
raise RuntimeError("migu 专辑 id 为空")
page_size = 50
seen: set[str] = set()
songs: list[dict[str, Any]] = []
total_count = 0
page_no = 1
while True:
body = await migu_get(
build_url(QUERY_ALBUM_SONG_URL, {"albumId": album, "pageNo": page_no, "pageSize": page_size})
)
body = body if isinstance(body, dict) else {}
if body.get("code") and body["code"] != "000000":
raise RuntimeError(f"migu api error: {body.get('info')} (code {body.get('code')})")
data = body.get("data") or {}
song_list = data.get("songList") or []
if not total_count:
total_count = int(to_number(data.get("totalCount")))
if not song_list:
break
before = len(songs)
for item in song_list:
song = convert_item_to_song(item, True)
if song is None:
continue
key = (
first_non_empty(
song["extra"].get("content_id"), item.get("contentId"), song["songmid"], item.get("copyrightId")
)
or f"{song['name']}|{song['singer']}"
)
if key in seen:
continue
seen.add(key)
songs.append(song)
if len(song_list) < page_size:
break
if total_count > 0 and len(songs) >= total_count:
break
if len(songs) == before:
break
page_no += 1
if not songs:
raise RuntimeError("migu 专辑没有可播放歌曲")
return {"songs": songs, "total": total_count or len(songs)}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""专辑详情(曲目 + resourceinfo 2003 的元数据),返回形状对齐 kg/kw 的专辑详情。"""
album = str(album_id or "").strip()
if not album:
raise RuntimeError("migu 专辑 id 为空")
fetched = await fetch_album_songs(album)
songs: list[dict[str, Any]] = fetched["songs"]
total: int = fetched["total"]
info: dict[str, str] = {"name": album, "img": "", "desc": "", "author": ""}
try:
body = await migu_get(
build_url(RESOURCE_INFO_URL, {"needSimple": "00", "resourceType": "2003", "resourceId": album})
)
body = body if isinstance(body, dict) else {}
if body.get("code") and body["code"] != "000000":
raise RuntimeError(str(body.get("info")))
resource = body.get("resource") or []
res = resource[0] if resource and isinstance(resource[0], dict) else None
if res:
info = {
"name": str(res.get("title") or "").strip() or album,
"img": pick_migu_image(res.get("imgItems")),
"desc": str(res.get("summary") or "").strip(),
"author": str(res.get("singer") or "").strip(),
}
if not info["img"] and songs[0].get("img"):
info["img"] = str(songs[0]["img"])
except Exception: # noqa: BLE001 - 元数据是锦上添花,失败仍返回曲目
if songs[0].get("img"):
info["img"] = str(songs[0]["img"])
start = (page - 1) * limit
return {
"source": "mg",
"info": info,
"list": songs[start : start + limit],
"page": page,
"limit": limit,
"total": total or len(songs),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
return (await fetch_album_songs(album_id))["songs"]
async def parse_album_link(link: str) -> dict[str, Any]:
raw = str(link or "")
for pattern in _ALBUM_PATTERNS:
match = pattern.search(raw)
if match:
return await get_album_detail(match.group(1))
raise RuntimeError("无效的咪咕专辑链接")
+49
View File
@@ -0,0 +1,49 @@
"""咪咕歌词(对应 sdk/mg/lyric.ts)。
resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本;歌词 CDN 只认 y.migu.cn 的 Referer,
与资源接口的 music.migu.cn Referer 不同,所以单独发一次请求。
上游对应 music-lib/migu/lyric.go。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.mg.util import SONG_DETAIL_URL, build_url, migu_get
LRC_REFERER = "https://y.migu.cn/"
LRC_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
)
_HTTP_PREFIX = re.compile(r"^http://")
def content_id_of(song_info: dict[str, Any]) -> str:
"""从 extra.content_id 或 songmid(contentId|resourceType|formatType)取 contentId。"""
extra = song_info.get("extra") or {}
if isinstance(extra, dict) and extra.get("content_id"):
return str(extra["content_id"])
raw_id = str(song_info.get("songmid") or song_info.get("id") or "")
return raw_id.split("|")[0]
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
content_id = content_id_of(song_info)
if not content_id:
raise RuntimeError("migu 歌曲 id 无效")
body = await migu_get(build_url(SONG_DETAIL_URL, {"resourceId": content_id, "resourceType": "2"}))
resource = body.get("resource") if isinstance(body, dict) else None
if not resource:
raise RuntimeError("migu 歌词资源不存在")
first = resource[0] if isinstance(resource[0], dict) else {}
lrc_url = str(first.get("lrcUrl") or "") or str(first.get("lyricUrl") or "")
if not lrc_url:
return {"lyric": "", "tlyric": ""}
lrc_url = _HTTP_PREFIX.sub("https://", lrc_url)
resp = await http_fetch(lrc_url, headers={"User-Agent": LRC_UA, "Referer": LRC_REFERER}, timeout=20_000)
if resp.status_code != 200:
raise RuntimeError(f"migu 歌词下载失败 {resp.status_code}")
return {"lyric": resp.body if isinstance(resp.body, str) else "", "tlyric": ""}
+60
View File
@@ -0,0 +1,60 @@
"""咪咕音乐搜索(对应 sdk/mg/musicSearch.ts)。
search_all.do(Android_migu 参数),searchSwitch 控制返回类别 —— 歌曲/专辑/歌单三个
子模块共用这一个接口,只切换 searchSwitch。
上游对应 music-lib/migu/migu.go 的 (*Migu).Search。
"""
from __future__ import annotations
import json
import math
from typing import Any
from app.sdk.platforms.mg.util import build_url, convert_item_to_song, migu_get, parse_int
SEARCH_ALL_URL = "http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do"
def search_switch(kind: str) -> str:
"""搜索开关:只开某一类(song/album/songlist),bestShow 保留最优推荐。"""
payload = {
"song": 1 if kind == "song" else 0,
"album": 1 if kind == "album" else 0,
"singer": 0,
"tagSong": 0,
"mvSong": 0,
"songlist": 1 if kind == "songlist" else 0,
"bestShow": 1,
}
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
async def search_all(text: str, kind: str, page: int = 1, limit: int = 20) -> Any:
url = build_url(
SEARCH_ALL_URL,
{
"ua": "Android_migu",
"version": "5.0.1",
"text": text,
"pageNo": page,
"pageSize": limit,
"searchSwitch": search_switch(kind),
},
)
return await migu_get(url)
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
body = await search_all(keyword, "song", page, limit)
data = body.get("songResultData") if isinstance(body, dict) else None
result = (data or {}).get("result") or []
songs = [song for item in result if (song := convert_item_to_song(item, True)) is not None]
total = parse_int((data or {}).get("totalCount")) or len(songs)
return {
"list": songs,
"allPage": max(1, math.ceil(total / limit)),
"total": total,
"limit": limit,
"source": "mg",
}
+44
View File
@@ -0,0 +1,44 @@
"""咪咕链接解析(对应 sdk/mg/index.ts 的 parseSong)。
歌曲:`https://music.migu.cn/v3/music/song/<contentId>` 或裸 contentId(6 位以上数字)。
专辑/歌单分别复用 album / playlist 的链接解析。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.mg.album import parse_album_link
from app.sdk.platforms.mg.playlist import parse_playlist_link
from app.sdk.platforms.mg.util import convert_item_to_song, fetch_song_detail
_SONG_PATTERNS = (
re.compile(r"music\.migu\.cn/v3/music/song/(\d+)"),
re.compile(r"^(\d{6,})$"),
)
async def parse_song(link: str) -> dict[str, Any]:
raw = str(link or "")
content_id = ""
for pattern in _SONG_PATTERNS:
match = pattern.search(raw)
if match:
content_id = match.group(1)
break
if not content_id:
raise RuntimeError("无效的咪咕歌曲链接")
item = await fetch_song_detail(content_id)
song = convert_item_to_song(item, True)
if song is None:
raise RuntimeError("该歌曲没有可用格式")
return song
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+187
View File
@@ -0,0 +1,187 @@
"""咪咕歌单(对应 sdk/mg/playlist.ts)。
搜索(search_all.do 的 songlist 开关)、曲目(MIGUM3.0 playlist/song/v2.0)、
分类(静态关键词表,上游同款,平台未提供分类接口)、链接解析。
上游对应 music-lib/migu/playlist.go。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.mg.music_search import search_all
from app.sdk.platforms.mg.util import (
build_url,
convert_item_to_song,
first_non_empty,
migu_get,
migu_playlist_link,
parse_int,
pick_migu_image,
to_number,
)
PLAYLIST_SONG_URL = "https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0"
_PLAYLIST_PATTERNS = (
re.compile(r"playlistId=(\d+)"),
re.compile(r"musicListId=(\d+)"),
re.compile(r"(?:playlist|songlist)/(\d+)"),
)
# 静态分类(上游 miguPlaylistCategoryKeywords)
CATEGORY_KEYWORDS: list[dict[str, Any]] = [
{"name": "华语", "group": "语种", "hot": True},
{"name": "欧美", "group": "语种", "hot": True},
{"name": "日语", "group": "语种"},
{"name": "韩语", "group": "语种"},
{"name": "粤语", "group": "语种"},
{"name": "流行", "group": "风格", "hot": True},
{"name": "摇滚", "group": "风格", "hot": True},
{"name": "民谣", "group": "风格", "hot": True},
{"name": "电子", "group": "风格"},
{"name": "说唱", "group": "风格"},
{"name": "古风", "group": "风格"},
{"name": "轻音乐", "group": "风格"},
{"name": "影视", "group": "场景", "hot": True},
{"name": "ACG", "group": "场景"},
{"name": "治愈", "group": "场景"},
{"name": "运动", "group": "场景"},
{"name": "学习", "group": "场景"},
{"name": "睡前", "group": "场景"},
]
def _to_playlist(item: dict[str, Any], category_id: str | None = None) -> dict[str, Any] | None:
playlist_id = str(item.get("id") or "").strip()
name = str(item.get("name") or "").strip()
if not playlist_id or not name:
return None
entry: dict[str, Any] = {
"id": playlist_id,
"name": name,
"cover": first_non_empty(item.get("musicListPicUrl"), pick_migu_image(item.get("imgItems"))),
"trackCount": parse_int(item.get("musicNum")),
"playCount": parse_int(item.get("playNum")),
"creator": first_non_empty(item.get("userName"), item.get("ownerName")),
"link": migu_playlist_link(playlist_id),
"source": "mg",
}
if category_id:
entry["categoryId"] = category_id
return entry
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索;分类歌单也用同一接口,关键词即分类名。"""
body = await search_all(keyword, "songlist", page, limit)
data = body.get("songListResultData") if isinstance(body, dict) else None
result = (data or {}).get("result") or []
playlists = [entry for item in result if (entry := _to_playlist(item)) is not None]
return {"list": playlists, "source": "mg", "page": page, "limit": limit, "total": len(playlists)}
async def get_categories() -> dict[str, Any]:
return {
"source": "mg",
"list": [
{"id": "", "name": "全部", "group": "全部"},
*(
{"id": item["name"], "name": item["name"], "group": item["group"], "hot": bool(item.get("hot"))}
for item in CATEGORY_KEYWORDS
),
],
}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
keyword = str(category_id or "").strip() or "华语"
found = await search_playlist(keyword, page, limit)
playlists = [entry for item in found["list"] if (entry := _to_playlist(item, keyword)) is not None]
return {"list": playlists, "source": "mg", "page": page, "limit": limit}
async def fetch_playlist_songs(playlist_id: str) -> dict[str, Any]:
"""歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs)。"""
playlist = str(playlist_id or "").strip()
if not playlist:
raise RuntimeError("migu 歌单 id 为空")
page_size = 50
seen: set[str] = set()
songs: list[dict[str, Any]] = []
total_count = 0
page_no = 1
while True:
body = await migu_get(
build_url(PLAYLIST_SONG_URL, {"pageNo": page_no, "pageSize": page_size, "playlistId": playlist})
)
body = body if isinstance(body, dict) else {}
if body.get("code") and body["code"] != "000000":
raise RuntimeError(f"migu api error: {body.get('info')} (code {body.get('code')})")
data = body.get("data") or {}
song_list = data.get("songList") or []
if not total_count:
total_count = int(to_number(data.get("totalCount")))
if not song_list:
break
before = len(songs)
for item in song_list:
song = convert_item_to_song(item, True)
if song is None:
continue
key = (
first_non_empty(
song["extra"].get("content_id"), item.get("contentId"), song["songmid"], item.get("copyrightId")
)
or f"{song['name']}|{song['singer']}"
)
if key in seen:
continue
seen.add(key)
songs.append(song)
if len(song_list) < page_size:
break
if total_count > 0 and len(songs) >= total_count:
break
if len(songs) == before:
break
page_no += 1
if not songs:
raise RuntimeError("migu 歌单没有可播放歌曲")
return {"songs": songs, "total": total_count or len(songs)}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
playlist = str(playlist_id or "").strip()
fetched = await fetch_playlist_songs(playlist)
songs: list[dict[str, Any]] = fetched["songs"]
total: int = fetched["total"]
start = (page - 1) * limit
return {
"source": "mg",
"info": {"name": playlist, "img": songs[0].get("img") or "", "desc": "", "author": ""},
"list": songs[start : start + limit],
"page": page,
"limit": limit,
"total": total or len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
return (await fetch_playlist_songs(playlist_id))["songs"]
async def parse_playlist_link(link: str) -> dict[str, Any]:
raw = str(link or "")
for pattern in _PLAYLIST_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("无效的咪咕歌单链接")
+460
View File
@@ -0,0 +1,460 @@
"""咪咕音乐公共层(对应 sdk/mg/util.ts)。
常量、请求封装、条目 → 歌曲的统一转换、档位/扩展名工具与 Z3D 解密。
上游对应 music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等)。
匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie
(接口返回「登录后免费试听」)。
Z3D 解密与 `app/http/mg_stream.py` 的流式实现同源:那边服务 /stream/mg 代理,
这里供取流探测阶段校验候选是否真能解出音频,两处算法必须逐字一致。
"""
from __future__ import annotations
import hashlib
import math
import re
from typing import Any
from urllib.parse import urlencode
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from app.sdk.auth_store import get_cookie
from app.sdk.common.request import http_fetch
# ---------------- 常量 ----------------
#: 咪咕 toneFlag → 平台音质标签(types 展示与取流返回共用)
TONE_QUALITY: dict[str, str] = {
"LQ": "64k",
"PQ": "128k",
"HQ": "320k",
"SQ": "flac",
"ZQ": "flac24bit",
"ZQ24": "flac24bit",
"ZQ32": "wav",
"Z3D": "wav",
"I3D": "flac24bit",
"3D60": "wav",
}
#: tone → 明文容器扩展名(取流侧判定档位与扩展名共用)
TONE_EXT: dict[str, str] = {
"LQ": "mp3",
"PQ": "mp3",
"HQ": "mp3",
"SQ": "flac",
"ZQ": "flac",
"ZQ24": "flac",
"ZQ32": "wav",
"Z3D": "wav",
"I3D": "m4a",
"3D60": "wav",
}
USER_AGENT = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
)
REFERER = "http://music.migu.cn/"
#: listen-url 走 Android 客户端 UA/头,与网页通道不同 UA
ANDROID_UA = "Android_migu/7.41.13 okhttp/3.12.13"
ANDROID_CHANNEL = "0146832"
ANDROID_VERSION = "7.41.13"
SONG_DETAIL_URL = "http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do"
# Z3D 加密流的 app code 密文(AES-128-CBC,key 为 AppKey 前 16 字符)
_ANDROID_APP_KEY = b"308202333082019c"
_ENCRYPTED_APP_CODE = bytes(
[
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
]
) # fmt: skip
_Z3D_IV = bytes(range(16))
_INT_PREFIX = re.compile(r"[+-]?\d+")
# ---------------- 请求 ----------------
def get_migu_cookie() -> str:
return get_cookie("mg").strip()
async def migu_get(url: str, extra_headers: dict[str, str] | None = None) -> Any:
"""带 Referer/UA/Cookie 的 GET,返回解析后的 body(JSON 已解析)。"""
headers: dict[str, str] = {"User-Agent": USER_AGENT, "Referer": REFERER, **(extra_headers or {})}
cookie = get_migu_cookie()
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(url, headers=headers, timeout=20_000)
if resp.status_code != 200:
raise RuntimeError(f"migu http {resp.status_code}")
return resp.body
def build_url(base: str, params: dict[str, Any]) -> str:
"""URLSearchParams 语义:值统一 String 后 form-urlencoded。"""
return f"{base}?{urlencode([(key, str(value)) for key, value in params.items()])}"
# ---------------- 工具函数(1:1 对应上游) ----------------
def parse_int(value: Any) -> int:
"""JS parseInt 语义:取前导整数,解析不出返回 0。"""
match = _INT_PREFIX.match(str(value if value is not None else "").strip())
return int(match.group()) if match else 0
def to_number(value: Any) -> float:
"""JS Number() 语义:不可解析或非有限值归零(分页 totalCount / 体积等字段用)。"""
try:
number = float(str(value if value is not None else "").strip())
except (TypeError, ValueError):
return 0.0
return number if math.isfinite(number) else 0.0
def first_non_empty(*values: Any) -> str:
for value in values:
if value is None:
continue
if str(value).strip():
return str(value)
return ""
def first_non_zero_string(*values: Any) -> str:
for value in values:
text = str(value if value is not None else "").strip()
if text and text != "0":
return text
return ""
def pick_migu_image(items: Any) -> str:
"""按 02/01/03 的尺寸优先级取图,都没有则取第一张非空。"""
if not isinstance(items, list):
return ""
for preferred in ("02", "01", "03"):
for item in items:
entry = item if isinstance(item, dict) else {}
if entry.get("imgSizeType") == preferred and str(entry.get("img") or "").strip():
return str(entry.get("img"))
for item in items:
entry = item if isinstance(item, dict) else {}
if str(entry.get("img") or "").strip():
return str(entry.get("img"))
return ""
def normalize_migu_image_url(image: Any) -> str:
url = str(image if image is not None else "").strip()
if not url:
return ""
if url.startswith(("http://", "https://")):
return url
if url.startswith("//"):
return f"https:{url}"
if url.startswith("/"):
return f"https://d.musicapp.migu.cn{url}"
return url
def collect_migu_artist_names(item: dict[str, Any]) -> list[str]:
names: list[str] = []
seen: set[str] = set()
def append(name: Any) -> None:
text = str(name if name is not None else "").strip()
if not text or text in seen:
return
seen.add(text)
names.append(text)
for group in ("singers", "singerList", "artists"):
for singer in item.get(group) or []:
append((singer or {}).get("name") if isinstance(singer, dict) else None)
if not names:
for name in str(item.get("singer") or "").split("|"):
append(name)
return names
_Z3D_CODE_KEYS = (
"formatType",
"resourceType",
"size",
"androidSize",
"isize",
"asize",
"fileType",
"androidFileType",
"iformat",
"aformat",
"price",
)
def collect_migu_formats(item: dict[str, Any]) -> list[dict[str, Any]]:
"""汇总三组 rateFormats + z3dCode,按 formatType|resourceType 去重。"""
groups: list[list[dict[str, Any]]] = []
for key in ("newRateFormats", "rateFormats", "audioFormats"):
group = item.get(key)
if isinstance(group, list):
groups.append([entry for entry in group if isinstance(entry, dict)])
code = item.get("z3dCode")
if isinstance(code, dict) and code.get("formatType"):
groups.append([{key: code.get(key) for key in _Z3D_CODE_KEYS}])
formats: list[dict[str, Any]] = []
seen: set[str] = set()
for group in groups:
for fmt in group:
key = f"{str(fmt.get('formatType') or '').strip().upper()}|{str(fmt.get('resourceType') or '').strip()}"
if key == "|" or key in seen:
continue
seen.add(key)
formats.append(fmt)
return formats
_FORMAT_RANK: dict[str, int] = {
"ZQ32": 80,
"ZQ": 75,
"ZQ24": 75,
"SQ": 70,
"HQ": 60,
"I3D": 50,
"Z3D": 50,
"3D60": 40,
"PQ": 30,
"LQ": 20,
}
def migu_format_rank(format_type: Any = "") -> int:
return _FORMAT_RANK.get(str(format_type or "").strip().upper(), 10)
def migu_format_ext(format_type: Any = "", format_code: Any = "") -> str:
tone = str(format_type or "").strip().upper()
code = str(format_code or "").strip()
if tone in ("SQ", "ZQ", "ZQ24"):
return "flac"
if tone in ("ZQ32", "Z3D", "3D60"):
return "wav"
if tone == "I3D":
return "m4a"
if tone in ("PQ", "HQ", "LQ"):
return "mp3"
return "flac" if code.startswith("011") else "mp3"
def normalize_tone(value: Any = "") -> str:
"""取流档位归一:去掉前导点、大写,只认 TONE_EXT 里已知的 toneFlag。"""
text = str(value if value is not None else "").strip()
if text.startswith("."):
text = text[1:]
text = text.upper()
return text if text in TONE_EXT else ""
def migu_album_link(album_id: str) -> str:
return f"https://music.migu.cn/v3/music/album/{album_id}"
def migu_playlist_link(playlist_id: str) -> str:
return f"https://music.migu.cn/v5/#/playlist?playlistId={playlist_id}&playlistType=ordinary"
def migu_song_link(content_id: str) -> str:
return f"https://music.migu.cn/v3/music/song/{content_id}"
def format_seconds(seconds: float) -> str:
minutes = math.floor(seconds / 60)
secs = math.floor(seconds % 60)
return f"{minutes:02d}:{secs:02d}"
# ---------------- 条目 → 歌曲(上游 convertItemToSongWithOption) ----------------
def _seconds_from_size(size: int, bitrate: int) -> int:
return math.floor(size * 8 / bitrate) if bitrate > 0 else 0
def convert_item_to_song(item: dict[str, Any], allow_paid: bool = True) -> dict[str, Any] | None:
artist_names = collect_migu_artist_names(item)
song_name = first_non_empty(item.get("name"), item.get("songName"))
album_name = str(item.get("album") or "").strip()
albums = item.get("albums") or []
if isinstance(albums, list) and albums:
first_album = albums[0] if isinstance(albums[0], dict) else {}
if str(first_album.get("name") or "").strip():
album_name = str(first_album.get("name")).strip()
formats = collect_migu_formats(item)
if not formats:
return None
duration = to_number(item.get("duration"))
download_tags = [str(tag or "").strip().lower() for tag in (item.get("downloadTags") or [])]
candidates: list[dict[str, Any]] = []
for index, fmt in enumerate(formats):
size_text = first_non_zero_string(fmt.get("androidSize"), fmt.get("asize"), fmt.get("size"), fmt.get("isize"))
size_value = parse_int(size_text)
ext = first_non_empty(fmt.get("androidFileType"), fmt.get("fileType"))
if not ext:
ext = migu_format_ext(fmt.get("formatType"), first_non_empty(fmt.get("aformat"), fmt.get("iformat")))
if not duration and size_value > 0:
bitrate = {"PQ": 128_000, "HQ": 320_000, "LQ": 64_000}.get(str(fmt.get("formatType") or ""), 0)
if bitrate > 0:
duration = _seconds_from_size(size_value, bitrate)
price_value = parse_int(fmt.get("price"))
show_tags = fmt.get("showTag")
tags = show_tags if isinstance(show_tags, list) and show_tags else (fmt.get("showTags") or [])
is_vip_tag = any(str(tag or "").strip().lower() == "vip" for tag in tags)
if not is_vip_tag:
is_vip_tag = "vip" in download_tags
hidden_paid = item.get("chargeAuditions") == "1" and price_value >= 200
if allow_paid or (not is_vip_tag and not hidden_paid):
candidates.append(
{
"index": index,
"size": size_value,
"ext": ext,
"rank": migu_format_rank(fmt.get("formatType")),
"vip": is_vip_tag or hidden_paid,
"tone": str(fmt.get("formatType") or ""),
}
)
if not candidates:
return None
candidates.sort(key=lambda entry: (-entry["rank"], -entry["size"]))
best = candidates[0]
best_format = formats[best["index"]]
cover_url = normalize_migu_image_url(
first_non_empty(
pick_migu_image(item.get("imgItems")),
pick_migu_image(item.get("albumImgs")),
item.get("img1"),
item.get("img2"),
item.get("img3"),
)
)
# albums 数组是搜索响应的专辑来源(顶层 albumId 常为空),与上游 Extra 的 album_id 同义
album_id = first_non_empty(item.get("albumId"), (albums[0] if albums else {}).get("id"))
link_id = first_non_empty(item.get("contentId"), item.get("copyrightId"))
resource_type = best_format.get("resourceType")
format_type = best_format.get("formatType")
extra: dict[str, str] = {
"content_id": str(item.get("contentId") or ""),
# 注意:extra 用空串兜底,songmid 用 '2' 兜底(与原文两处写法不同)
"resource_type": str(resource_type if resource_type is not None else ""),
"format_type": str(format_type if format_type is not None else ""),
}
if item.get("copyrightId"):
extra["copyright_id"] = str(item["copyrightId"])
if item.get("songId"):
extra["song_id"] = str(item["songId"])
if album_id:
extra["album_id"] = album_id
is_vip = item.get("chargeAuditions") == "1" or bool(best["vip"])
songmid = f"{item.get('contentId') or ''}|{resource_type if resource_type is not None else '2'}|{format_type or ''}"
# 各档位可用性(供前端展示);实际可取档位由取流接口的探测结果决定
types = [{"type": TONE_QUALITY.get(entry["tone"], "128k"), "size": str(entry["size"])} for entry in candidates]
return {
"songmid": songmid,
"name": song_name,
"singer": " / ".join(artist_names),
"albumId": album_id,
"albumName": album_name,
"interval": format_seconds(duration) if duration else "0",
"img": cover_url or None,
"lrc": None,
"types": types,
"_types": {},
"payPlay": is_vip,
# 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里
"extra": {**extra, "link": migu_song_link(link_id)},
"source": "mg",
}
# ---------------- 歌曲详情 ----------------
async def fetch_song_detail(content_id: str) -> dict[str, Any]:
url = build_url(SONG_DETAIL_URL, {"resourceType": "2", "resourceId": content_id})
body = await migu_get(url)
resource = body.get("resource") if isinstance(body, dict) else None
if not resource:
raise RuntimeError("migu 歌曲详情为空")
return resource[0]
# ---------------- Z3D 解密(上游 DecryptAudio / miguZ3DStreamKey) ----------------
def is_migu_audio_magic(data: bytes) -> bool:
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
if not data or len(data) < 4:
return False
if data[:4] in (b"fLaC", b"OggS"):
return True
if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WAVE":
return True
if data[:3] == b"ID3":
return True
if len(data) >= 12 and data[4:8] == b"ftyp":
return True
return data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
def migu_z3d_stream_key(file_key: str) -> bytes:
"""streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制(32 字节 ASCII)。"""
key = str(file_key or "").strip()
if not key:
raise RuntimeError("空的 z3d fileKey")
# Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,这里同样禁用填充,否则尾块校验失败
decryptor = Cipher(algorithms.AES(_ANDROID_APP_KEY), modes.CBC(_Z3D_IV)).decryptor()
app_code = decryptor.update(_ENCRYPTED_APP_CODE) + decryptor.finalize()
digest = hashlib.md5(app_code + key.encode("utf-8")).hexdigest().upper()
return digest.encode("ascii")
def decrypt_z3d(data: bytes, file_key: str) -> bytes:
"""Z3D 流解密:逐字节 `plain[i] = cipher[i] - key[i % 32]`;已是明文则原样返回。"""
if not data:
raise RuntimeError("空的 z3d 数据")
if is_migu_audio_magic(data):
return data
payload = data[3:] if data[:3] == b"Z3D" else data
if not payload:
raise RuntimeError("剥离 Z3D 魔数后无数据")
stream_key = migu_z3d_stream_key(file_key)
plain = bytes((byte - stream_key[index & 31]) & 0xFF for index, byte in enumerate(payload))
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain
+65
View File
@@ -0,0 +1,65 @@
"""蜻蜓FM 平台模块(对应 sdk/qt/index.ts)。
匿名可用面(2026-10-02 实测):搜索(官网 SSR 解析)/ 频道节目列表(SSR)/
免费节目点播直链(audiostream 签名 302 到华为云 CDN)。
搜索结果为频道(专辑)级条目,songmid=channelId;节目条目 songmid="<channelId>:<programId>"。
付费节目与直播电台签名(Lwrpu$K5oP)暂未接登录。有声内容不参与聚合(noAggregate)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.qt import album, music_search
async def _get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
from app.sdk.apis.qt import get_music_url
return await get_music_url(song_info, quality, retry_num)
async def get_pic(song_info: dict[str, Any]) -> str | None:
"""搜索结果已带封面;频道详情页无独立封面接口,降级用 SSR 页 og:image。"""
channel_id = str(song_info.get("songmid") or "").split(":")[0]
return await album.get_channel_og_image(channel_id)
async def get_album_detail(channel_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
del page, limit # SSR 页一次吐出全部节目,与上游一致不分页
result = await album.get_channel_programs(channel_id)
songs = result["list"]
return {
"info": {"name": result["name"], "total": len(songs)},
"list": songs,
"total": len(songs),
"limit": len(songs),
"allPage": 1,
"source": "qt",
}
class _QtAlbum:
get_album_detail = staticmethod(get_album_detail)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
parts = str(song_info.get("songmid") or "").split(":")
channel_id = parts[0] if parts else ""
program_id = parts[1] if len(parts) > 1 else ""
if program_id:
return f"https://www.qingting.fm/channels/{channel_id}/programs/{program_id}"
return f"https://www.qingting.fm/channels/{channel_id}"
MODULE = Platform(
id="qt",
name="蜻蜓FM",
music_search=music_search,
album=_QtAlbum,
get_music_url=_get_music_url,
get_pic=get_pic,
get_music_detail_page_url=get_music_detail_page_url,
)
+68
View File
@@ -0,0 +1,68 @@
"""蜻蜓FM 频道节目列表(对应 sdk/qt/album.ts,官网 SSR 页解析)。
接口: https://www.qingting.fm/channels/<id>(302 → www.qtfm.cn,见 http.py)
实测(2026-10-02)SSR 输出含节目表格:a.pTitle href=/channels/<cid>/programs/<pid> + p[title] + span.col2 时长
节目条目的 songmid 采用复合 id ``<channelId>:<programId>``(取流签名需要两者)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.qt.http import ssr_get
def _decode_entities(text: str) -> str:
return (
text.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", '"')
.replace("&#39;", "'")
)
async def _fetch_channel_html(channel_id: str) -> str:
result = await ssr_get(f"https://www.qingting.fm/channels/{channel_id}")
if result["statusCode"] != 200:
raise RuntimeError(f"获取频道失败(HTTP {result['statusCode']})")
return str(result["html"])
async def get_channel_programs(channel_id: str) -> dict[str, Any]:
html = await _fetch_channel_html(channel_id)
title_match = re.search(r"<title>([^<]*)<", html)
raw_name = title_match.group(1).split("-")[0].strip() if title_match else ""
name = _decode_entities(raw_name or str(channel_id))
songs = []
for match in re.finditer(r'href="/channels/(\d+)/programs/(\d+)"[^>]*>\s*<p[^>]*title="([^"]*)"', html):
songs.append(
{
"songmid": f"{match.group(1)}:{match.group(2)}",
"name": _decode_entities(match.group(3)) or f"节目 {match.group(2)}",
"singer": name,
"albumId": str(channel_id),
"albumName": name,
"interval": 0,
"payPlay": False,
"img": None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
return {"name": name, "list": songs}
async def get_channel_og_image(channel_id: str) -> str | None:
"""频道封面(SSR 页 og:image)。"""
html = await _fetch_channel_html(channel_id)
match = re.search(r'property="og:image"\s+content="([^"]+)"', html) or re.search(
r'content="([^"]+)"\s+property="og:image"', html
)
return match.group(1).replace("http://", "https://") if match else None
+44
View File
@@ -0,0 +1,44 @@
"""蜻蜓FM SSR 请求助手(对应 sdk/qt/http.ts)。
域名迁移(www.qingting.fm → www.qtfm.cn)伴随华为 WAF 会话:
302 时需带上 Set-Cookie 的 HWWAFSESID/HWWAFSESTIME 再请求 Location,否则可能被 WAF 拦。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urljoin
from app.sdk.common.request import http_fetch
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
async def ssr_get(url: str) -> dict[str, Any]:
"""GET 并跟随最多 3 跳重定向(累计收集 Cookie 回带),返回最终响应 ``{statusCode, html}``。"""
current = url
cookie = ""
for _hop in range(4):
headers: dict[str, str] = {
"User-Agent": UA,
"Referer": "https://www.qtfm.cn/",
"Accept": "text/html,application/xhtml+xml",
}
if cookie:
headers["Cookie"] = cookie
resp = await http_fetch(current, headers=headers, timeout=15_000)
pairs = [item.split(";")[0].strip() for item in resp.set_cookies]
pairs = [pair for pair in pairs if pair]
if pairs:
cookie = "; ".join([part for part in cookie.split("; ") if part] + pairs)
if 300 <= resp.status_code < 400:
location = str(resp.headers.get("location") or "")
if location:
current = location if location.startswith("http") else urljoin(current, location)
continue
return {
"statusCode": resp.status_code,
"html": resp.raw.decode("utf-8", errors="replace") if resp.raw else str(resp.body or ""),
}
raise RuntimeError("重定向次数过多")
+54
View File
@@ -0,0 +1,54 @@
"""蜻蜓FM 搜索(对应 sdk/qt/musicSearch.ts,官网 SSR 频道结果页解析)。
接口: https://www.qingting.fm/search/channel_ondemand/<keyword>(302 → www.qtfm.cn,见 http.py)
实测(2026-10-02)SSR 渲染且与关键词强相关;?keyword= 混合页的结果区为客户端渲染
(服务端只吐热门推荐),JSON 搜索端点(webapi/webbff/searchapi)均 404——本页是唯一匿名搜索通道。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.platforms.fivesing.util import encode_uri_component
from app.sdk.platforms.qt.http import ssr_get
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
del retry_num # 契约参数,SSR 搜索无重试语义
result = await ssr_get(
f"https://www.qingting.fm/search/channel_ondemand/{encode_uri_component(keyword)}?page={page}"
)
if result["statusCode"] != 200:
raise RuntimeError(f"搜索失败(HTTP {result['statusCode']})")
html = str(result["html"])
# 结果项:href=/channels/<id> … <span title="<名称>">,封面在随后的 img src
items = []
for match in re.finditer(r'href="/channels/(\d+)"[\s\S]{0,400}?<span title="([^"]*)">', html):
after = html[match.start() : match.start() + 700]
img = re.search(r'<img src="(http[^"]+)"', after)
items.append(
{
"songmid": match.group(1),
"name": match.group(2),
"singer": "",
"albumId": "",
"albumName": "",
"interval": 0,
"img": img.group(1).replace("http://", "https://") if img else None,
"lrc": "",
"lrcx": "",
"types": [],
"_types": {},
"disabled": False,
}
)
songs = [item for item in items if item["name"]][:limit]
return {
"allPage": page + (1 if len(songs) >= 10 else 0),
"total": len(songs),
"limit": limit,
"source": "qt",
"list": songs,
}
+62
View File
@@ -0,0 +1,62 @@
"""QQ音乐(对应 sdk/tx/index.ts 的 default export)。
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析 / 个人歌单,
顶层挂 get_music_url(取流在 apis/tx.py)、get_lyric、get_pic、get_music_info /
handle_music_info(songmid → SongInfo,供酷我兜底与评论/歌词反查数字 songId)、
get_music_detail_page_url。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.tx import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_info,
music_search,
parse,
pic,
playlist,
singer,
song_list,
user_playlist,
)
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口。延迟导入 apis.tx:apis 模块反向依赖 platforms.tx.utils,
模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块(wy 已踩过)。"""
from app.sdk.apis import tx as tx_api
return await tx_api.get_music_url(song_info, quality, retry_num)
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"https://y.qq.com/n/yqq/song/{song_info.get('songmid')}.html"
MODULE = Platform(
id="tx",
name="QQ音乐",
music_search=music_search,
leaderboard=leaderboard,
song_list=song_list,
hot_search=hot_search,
comment=comment,
singer=singer,
album=album,
playlist=playlist,
parse=parse,
user_playlist=user_playlist,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=pic.get_pic,
get_music_info=music_info.get_music_info,
handle_music_info=music_info.handle_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+183
View File
@@ -0,0 +1,183 @@
"""QQ音乐专辑(移植自 sdk/tx/album.ts)。
三条路径:
* 详情 —— music.musichallAlbum.AlbumSongList/GetAlbumSongList(参数名必须是驼峰
`albumMid`,下划线形态会被上游以 104400 拒绝)
* 全量曲目 —— 同一接口按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
* 搜索 —— c.y.qq.com 的 search_for_qq_cp `t=8`
GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,
封面走平台固定图片 URL 规则。
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.filter import album_img, music_info_item
from app.sdk.platforms.tx.utils import create_musicu_fetch
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
ALBUM_BATCH = 100
SEARCH_URL = "http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p={page}&n={limit}&w={query}&t=8"
_ALBUM_PATTERNS: tuple[re.Pattern[str], ...] = (
re.compile(r"albumDetail/([A-Za-z0-9]+)"),
re.compile(r"/album/([A-Za-z0-9]+)"),
re.compile(r"albummid=([A-Za-z0-9]+)"),
)
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
"""GetAlbumSongList 的 songList(每项包一层 {songInfo})→ SongInfo 列表。"""
return [music_info_item((item or {}).get("songInfo")) for item in raw or []]
def album_link(mid: str) -> str:
return f"https://y.qq.com/n/ryqq/albumDetail/{mid}"
async def fetch_album_songs(album_id: str) -> dict[str, Any]:
"""专辑曲目全量(GetAlbumSongList 100 一批翻页)。
终止条件与原文一致:本批为空、本批不足一批、或已翻到 totalNum 为止。
"""
album_mid = str(album_id or "").strip()
if not album_mid:
raise RuntimeError("tx 专辑 id 为空")
songs: list[dict[str, Any]] = []
total_num = 0
begin = 0
while True:
body = await create_musicu_fetch(
{
"req": {
"module": "music.musichallAlbum.AlbumSongList",
"method": "GetAlbumSongList",
"param": {"albumMid": album_mid, "begin": begin, "num": ALBUM_BATCH, "order": 2},
}
}
)
req = body.get("req") or {}
if req.get("code") != 0:
raise RuntimeError("get album song list faild.")
data = req.get("data") or {}
batch = data.get("songList") or []
if not total_num:
total_num = int(data.get("totalNum") or 0)
if not batch:
break
songs.extend(batch)
if len(batch) < ALBUM_BATCH:
break
if total_num > 0 and begin + len(batch) >= total_num:
break
begin += ALBUM_BATCH
first: dict[str, Any] = {}
if songs:
first = (songs[0] or {}).get("songInfo") or {}
return {
"raw": songs,
"songs": filter_song_list(songs),
"total": total_num or len(songs),
"albumMid": album_mid,
"albumName": (first.get("album") or {}).get("name"),
}
async def get_album_detail(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
"""专辑详情 + 曲目(路由优先走这个签名:get_album_detail(id, page, limit))。"""
body = await create_musicu_fetch(
{
"req": {
"module": "music.musichallAlbum.AlbumSongList",
"method": "GetAlbumSongList",
"param": {"albumMid": id, "begin": (page - 1) * limit, "num": limit},
}
}
)
req = body.get("req") or {}
if req.get("code") != 0:
raise RuntimeError("get album song list faild.")
data = req.get("data") or {}
song_list = data.get("songList") or []
first: dict[str, Any] = {}
if song_list:
first = (song_list[0] or {}).get("songInfo") or {}
authors: list[str] = []
for item in song_list:
for singer in ((item or {}).get("songInfo") or {}).get("singer") or []:
name = (singer or {}).get("name")
if name and name not in authors:
authors.append(name)
return {
"source": "tx",
"info": {
"name": (first.get("album") or {}).get("name"),
"img": album_img(str(id)),
"desc": None,
"author": "、".join(authors) or None,
},
"list": filter_song_list(song_list),
"page": page,
"limit": limit,
"total": data.get("totalNum"),
}
async def get_album_songs(id: str) -> list[dict[str, Any]]:
return (await fetch_album_songs(id))["songs"]
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8)。"""
resp = await http_fetch(
SEARCH_URL.format(page=page, limit=limit, query=quote(str(keyword or ""), safe="")),
headers={"User-Agent": _UA, "Referer": "https://y.qq.com/portal/search.html"},
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
raw_data = body.get("data")
album_data: dict[str, Any] = (raw_data.get("album") if isinstance(raw_data, dict) else None) or {}
if resp.status_code != 200 or not album_data:
raise RuntimeError("tx 专辑搜索失败")
rows: list[dict[str, Any]] = []
for item in album_data.get("list") or []:
mid = str((item or {}).get("albumMID") or "").strip()
if not mid:
continue
rows.append(
{
"id": mid,
"name": str(item.get("albumName") or "").strip(),
"cover": f"https://y.gtimg.cn/music/photo_new/T002R300x300M000{mid}.jpg",
"trackCount": int(item.get("song_count") or item.get("songCount") or 0),
"creator": str(item.get("singerName") or "").strip(),
"description": "",
"link": album_link(mid),
"type": "album",
"albumId": str(item.get("albumID") or ""),
"publishTime": str(item.get("publicTime") or ""),
"source": "tx",
}
)
total = int(album_data.get("totalnum") or album_data.get("total") or len(rows)) or len(rows)
return {"list": rows, "page": page, "limit": limit, "total": total, "source": "tx"}
async def parse_album_link(link: str) -> dict[str, Any]:
"""专辑链接解析(上游 ParseAlbum):命中任一形态即取详情,否则原文案报错。"""
raw = str(link or "")
for pattern in _ALBUM_PATTERNS:
match = pattern.search(raw)
if match:
return await get_album_detail(match.group(1))
raise RuntimeError("无效的QQ音乐专辑链接")
+341
View File
@@ -0,0 +1,341 @@
"""QQ音乐评论(移植自 sdk/tx/comment.ts)。
两路数据源(与原文一致,接口不同、返回形状不同):
* 最新评论 —— c.y.qq.com 的 fcg_global_comment_h5(form 表单,cmd=8)
* 热门评论 —— musicu.fcg 的 music.globalComment.CommentRead/GetHotCommentList
原文靠 `_requestObj.cancelHttp()` 做并发去重;Python 请求层没有取消句柄(见
common/request.py 的说明),这里改用 asyncio.Task 合并同一 songmid 的并发查询。
两处刻意的降级/容错:
* `middlecommentcontent` 的父评论头像与点赞数会被搬到第一条楼中楼,父条目置 null,
随后楼中楼整体反转 —— 照抄原文,否则前端展示顺序与字段归属会和 TS 版不一致;
* 原文 `c.replynick.substring(1)` 在 replynick 缺失时会抛 TypeError 把整页打挂,
这里按空串处理(唯一的偏离,只影响脏数据)。
"""
from __future__ import annotations
import asyncio
import math
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format2
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
from app.sdk.platforms.tx.utils import MUSICU_URL
EMOJIS: dict[str, str] = {
"e400846": "😘",
"e400874": "😴",
"e400825": "😃",
"e400847": "😙",
"e400835": "😍",
"e400873": "😳",
"e400836": "😎",
"e400867": "😭",
"e400832": "😊",
"e400837": "😏",
"e400875": "😫",
"e400831": "😉",
"e400855": "😡",
"e400823": "😄",
"e400862": "😨",
"e400844": "😖",
"e400841": "😓",
"e400830": "😈",
"e400828": "😆",
"e400833": "😋",
"e400822": "😀",
"e400843": "😕",
"e400829": "😇",
"e400824": "😂",
"e400834": "😌",
"e400877": "😷",
"e400132": "🍉",
"e400181": "🍺",
"e401067": "☕️",
"e400186": "🥧",
"e400343": "🐷",
"e400116": "🌹",
"e400126": "🍃",
"e400613": "💋",
"e401236": "❤️",
"e400622": "💔",
"e400637": "💣",
"e400643": "💩",
"e400773": "🔪",
"e400102": "🌛",
"e401328": "🌞",
"e400420": "👏",
"e400914": "🙌",
"e400408": "👍",
"e400414": "👎",
"e401121": "✋",
"e400396": "👋",
"e400384": "👉",
"e401115": "✊",
"e400402": "👌",
"e400905": "🙈",
"e400906": "🙉",
"e400907": "🙊",
"e400562": "👻",
"e400932": "🙏",
"e400644": "💪",
"e400611": "💉",
"e400185": "🎁",
"e400655": "💰",
"e400325": "🐥",
"e400612": "💊",
"e400198": "🎉",
"e401685": "⚡️",
"e400631": "💝",
"e400768": "🔥",
"e400432": "👑",
}
_UA_IE = "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
_UA_HOT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36 Edg/113.0.0.0"
)
_NEW_COMMENT_URL = "http://c.y.qq.com/base/fcgi-bin/fcg_global_comment_h5.fcg"
_EMOJI_RE = re.compile(r"\[em\](e\d+)\[/em\]")
# songmid → songId,以及并发查询合并用的 task 表
_song_id_map: dict[str, Any] = {}
_promises: dict[str, asyncio.Task[Any]] = {}
def replace_emoji(msg: str) -> str:
"""`[em]e400846[/em]` → emoji;未知编号替换为空串(与原文 `|| ''` 一致)。"""
if not msg or "[em]" not in msg:
return msg
return _EMOJI_RE.sub(lambda match: EMOJIS.get(match.group(1), ""), msg)
def format_time(time: Any) -> int | None:
"""原文字符串长度 < 10 视为无效时间返回 null;否则补 `000` 转毫秒。"""
text = str(time)
if len(text) < 10:
return None
try:
return int(text + "000")
except ValueError:
return None
async def get_song_id(song_info: dict[str, Any]) -> Any:
"""songId 优先;否则 songmid 反查(带缓存 + 并发合并)。"""
song_id = song_info.get("songId")
if song_id:
return song_id
songmid = str(song_info.get("songmid") or "")
if songmid in _song_id_map:
return _song_id_map[songmid]
task = _promises.get(songmid)
if task is None:
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
_promises[songmid] = task
try:
info = await asyncio.shield(task)
finally:
_promises.pop(songmid, None)
if not info:
raise RuntimeError("获取歌曲信息失败")
_song_id_map[songmid] = info.get("songId")
return info.get("songId")
def _time_pair(time: Any) -> tuple[int | None, str | None]:
parsed = format_time(time)
return parsed, (date_format2(parsed) if parsed else None)
def filter_new_comment(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
if not isinstance(item, dict):
continue
time, time_str = _time_pair(item.get("time"))
middle = item.get("middlecommentcontent")
if middle:
# 父条目头像/点赞搬到第一条楼中楼,父条目置空,楼中楼反转(照抄原文)
first = middle[0]
first["avatarurl"] = item.get("avatarurl")
first["praisenum"] = item.get("praisenum")
item["avatarurl"] = None
item["praisenum"] = None
middle.reverse()
replies: list[dict[str, Any]] = []
for reply in middle or []:
if not isinstance(reply, dict):
continue
reply_time = time if reply.get("subcommentid") == item.get("commentid") else None
reply_time_str = time_str if reply.get("subcommentid") == item.get("commentid") else None
replies.append(
{
"id": f"sub_{item.get('rootcommentid')}_{reply.get('subcommentid')}",
"text": replace_emoji(str(reply.get("subcommentcontent") or "")).replace("\\n", "\n"),
"time": reply_time,
"timeStr": reply_time_str,
"userName": str(reply.get("replynick") or "")[1:],
"avatar": reply.get("avatarurl"),
"userId": reply.get("encrypt_replyuin"),
"likedCount": reply.get("praisenum"),
}
)
same_root = item.get("rootcommentid") == item.get("commentid")
rows.append(
{
"id": f"{item.get('rootcommentid')}_{item.get('commentid')}",
"rootId": item.get("rootcommentid"),
"text": replace_emoji(str(item.get("rootcommentcontent") or "")).replace("\\n", "\n")
if item.get("rootcommentcontent")
else "",
"time": time if same_root else None,
"timeStr": time_str if same_root else None,
"userName": str(item.get("rootcommentnick") or "")[1:],
"avatar": item.get("avatarurl"),
"userId": item.get("encrypt_rootcommentuin"),
"likedCount": item.get("praisenum"),
"reply": replies,
}
)
return rows
def filter_hot_comment(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
if not isinstance(item, dict):
continue
time, time_str = _time_pair(item.get("PubTime")) if item.get("PubTime") else (None, None)
replies: list[dict[str, Any]] = []
for reply in item.get("SubComments") or []:
if not isinstance(reply, dict):
continue
reply_time, reply_time_str = _time_pair(reply.get("PubTime")) if reply.get("PubTime") else (None, None)
replies.append(
{
"id": f"sub_{reply.get('SeqNo')}_{reply.get('CmId')}",
"text": replace_emoji(str(reply.get("Content") or "")).replace("\\n", "\n"),
"time": reply_time,
"timeStr": reply_time_str,
"userName": reply.get("Nick") or "",
"avatar": reply.get("Avatar"),
"images": [reply["Pic"]] if reply.get("Pic") else [],
"userId": reply.get("EncryptUin"),
"likedCount": reply.get("PraiseNum"),
}
)
rows.append(
{
"id": f"{item.get('SeqNo')}_{item.get('CmId')}",
"rootId": item.get("SeqNo"),
"text": replace_emoji(str(item.get("Content") or "")).replace("\\n", "\n")
if item.get("Content")
else "",
"time": time,
"timeStr": time_str,
"userName": item.get("Nick") or "",
"images": [item["Pic"]] if item.get("Pic") else [],
"avatar": item.get("Avatar"),
"location": item.get("Location") or "",
"userId": item.get("EncryptUin"),
"likedCount": item.get("PraiseNum"),
"reply": replies,
}
)
return rows
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
"""最新评论(fcg_global_comment_h5,form 表单)。失败文案与原文一致:`获取评论失败`。"""
song_id = await get_song_id(song_info)
resp = await http_fetch(
_NEW_COMMENT_URL,
method="POST",
headers={"User-Agent": _UA_IE},
form={
"uin": "0",
"format": "json",
"cid": "205360772",
"reqtype": "2",
"biztype": "1",
"topid": song_id,
"cmd": "8",
"needmusiccrit": "1",
"pagenum": page - 1,
"pagesize": limit,
},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
raise RuntimeError("获取评论失败")
comment = body.get("comment") or {}
total = comment.get("commenttotal") or 0
return {
"source": "tx",
"comments": filter_new_comment(comment.get("commentlist")),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
"""热门评论(musicu.fcg GetHotCommentList)。失败文案:`获取热门评论失败`。"""
song_id = await get_song_id(song_info)
resp = await http_fetch(
MUSICU_URL,
method="POST",
body={
"comm": {
"cv": 4747474,
"ct": 24,
"format": "json",
"inCharset": "utf-8",
"outCharset": "utf-8",
"notice": 0,
"platform": "yqq.json",
"needNewCode": 1,
"uin": 0,
},
"req": {
"module": "music.globalComment.CommentRead",
"method": "GetHotCommentList",
"param": {
"BizType": 1,
"BizId": str(song_id),
"LastCommentSeqNo": "",
"PageSize": limit,
"PageNum": page - 1,
"HotType": 1,
"WithAirborne": 0,
"PicEnable": 1,
},
},
},
headers={"User-Agent": _UA_HOT, "referer": "https://y.qq.com/", "origin": "https://y.qq.com"},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if resp.status_code != 200 or body.get("code") != 0 or req.get("code") != 0:
raise RuntimeError("获取热门评论失败")
comment = (req.get("data") or {}).get("CommentList") or {}
total = comment.get("Total") or 0
return {
"source": "tx",
"comments": filter_hot_comment(comment.get("Comments")),
"total": total,
"page": page,
"limit": limit,
"maxPage": math.ceil(total / limit) if limit else 1,
}
+80
View File
@@ -0,0 +1,80 @@
"""QQ音乐 SongInfo 组装公共片段。
原 TS 把这套「file.size_* → types/_types」与「专辑图/歌手图」逻辑在
musicSearch / songList / leaderboard / singer / parse 里各抄了一份。迁移时合并到一处,
字段与判定顺序保持原样(顺序影响前端音质下拉的排列)。
"""
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
# 顺序即原实现的 push 顺序:(file 字段, 对外音质名)
_SIZE_FIELDS: tuple[tuple[str, str], ...] = (
("size_128mp3", "128k"),
("size_320mp3", "320k"),
("size_flac", "flac"),
("size_hires", "flac24bit"),
)
def build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""file 体积字段 → (types, _types)。0/缺失表示该档不存在(与原文 `!== 0` 一致)。"""
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for key, quality in _SIZE_FIELDS:
if file.get(key):
size = size_formate(file[key])
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
return types, _types
def song_img(album_mid: Any, singers: Any) -> str:
"""有专辑 mid 用专辑图(T002),否则退回首位歌手头像(T001)。"""
if album_mid in ("", "空", None):
if isinstance(singers, list) and singers and isinstance(singers[0], dict) and singers[0].get("mid"):
return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg"
return ""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg"
def album_img(album_mid: Any) -> str:
"""专辑封面(singer 专辑列表、专辑搜索用,不退化到歌手图)。"""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_mid}.jpg"
def music_info_item(item: Mapping[str, Any] | None) -> dict[str, Any]:
"""原始曲目条目 → SongInfo(对应 singer.ts 的 filterMusicInfoItem)。
比 music_search/filter_list 的版本多带 lrc/otherSource 两个空字段 —— 原文
只在 filterMusicInfoItem 里带,专辑/歌手曲目沿用这个形状,不要合并掉。
"""
raw: Mapping[str, Any] = item or {}
file = raw.get("file") or {}
types, _types = build_types(file)
album = raw.get("album") or {}
album_name = album.get("name") or ""
album_mid = album.get("mid") or ""
singers = raw.get("singer") or []
return {
"source": "tx",
"singer": format_singer_name(singers, "name"),
"name": raw.get("title"),
"albumName": album_name,
"albumId": album_mid,
"albumMid": album_mid,
"interval": format_play_time(raw.get("interval")),
"songId": raw.get("id"),
"songmid": raw.get("mid"),
"strMediaMid": file.get("media_mid"),
"img": song_img("" if album_name in ("", "空") else album_mid, singers),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
+52
View File
@@ -0,0 +1,52 @@
"""QQ音乐热搜(移植自 sdk/tx/hotSearch.ts)。
musicu.fcg 的 tencent_musicsoso_hotkey.HotkeyService/GetHotkeyForQQMusicPC。
失败文案与原文一致:`获取热搜词失败`;重试上限 3 次(try max num)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.utils import MUSICU_URL
def filter_list(raw_list: Any) -> list[str]:
return [str(item.get("query")) for item in raw_list or [] if isinstance(item, dict) and item.get("query")]
async def get_list(retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"Referer": "https://y.qq.com/portal/player.html"},
body={
"comm": {
"ct": "19",
"cv": "1803",
"guid": "0",
"patch": "118",
"psrf_access_token_expiresAt": 0,
"psrf_qqaccess_token": "",
"psrf_qqopenid": "",
"psrf_qqunionid": "",
"tmeAppID": "qqmusic",
"tmeLoginType": 0,
"uin": "0",
"wid": "0",
},
"hotkey": {
"method": "GetHotkeyForQQMusicPC",
"module": "tencent_musicsoso_hotkey.HotkeyService",
"param": {"search_id": "", "uin": 0},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 0:
raise RuntimeError("获取热搜词失败")
return {"source": "tx", "list": filter_list(((body.get("hotkey") or {}).get("data") or {}).get("vec_hotkey"))}
+150
View File
@@ -0,0 +1,150 @@
"""QQ音乐榜单(移植自 sdk/tx/leaderboard.ts)。
榜单列表是硬编码的 25 个(boardList);详情走 musicu.fcg 的
musicToplist.ToplistInfoServer/GetDetail,period 参数(如 2026_31)从 top.html
按正则抓取并按 bangid 缓存。抓不到就传空串 —— 接口对缺省 period 容忍,
与原文 `info ? info.period : getPeriods(bangid)` 的降级一致。
上游不分页:一次返回 LIMIT 首,page 入参仅为对齐协议。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, format_singer_name
from app.sdk.platforms.tx.filter import build_types, song_img
from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU
LIMIT = 300
BOARD_LIST: list[dict[str, str]] = [
{"id": "tx__4", "name": "流行指数榜", "bangid": "4"},
{"id": "tx__26", "name": "热歌榜", "bangid": "26"},
{"id": "tx__27", "name": "新歌榜", "bangid": "27"},
{"id": "tx__62", "name": "飙升榜", "bangid": "62"},
{"id": "tx__58", "name": "说唱榜", "bangid": "58"},
{"id": "tx__57", "name": "喜力电音榜", "bangid": "57"},
{"id": "tx__28", "name": "网络歌曲榜", "bangid": "28"},
{"id": "tx__5", "name": "内地榜", "bangid": "5"},
{"id": "tx__3", "name": "欧美榜", "bangid": "3"},
{"id": "tx__59", "name": "香港地区榜", "bangid": "59"},
{"id": "tx__16", "name": "韩国榜", "bangid": "16"},
{"id": "tx__60", "name": "抖快榜", "bangid": "60"},
{"id": "tx__29", "name": "影视金曲榜", "bangid": "29"},
{"id": "tx__17", "name": "日本榜", "bangid": "17"},
{"id": "tx__52", "name": "腾讯音乐人原创榜", "bangid": "52"},
{"id": "tx__36", "name": "K歌金曲榜", "bangid": "36"},
{"id": "tx__61", "name": "台湾地区榜", "bangid": "61"},
{"id": "tx__63", "name": "DJ舞曲榜", "bangid": "63"},
{"id": "tx__64", "name": "综艺新歌榜", "bangid": "64"},
{"id": "tx__65", "name": "国风热歌榜", "bangid": "65"},
{"id": "tx__67", "name": "听歌识曲榜", "bangid": "67"},
{"id": "tx__72", "name": "动漫音乐榜", "bangid": "72"},
{"id": "tx__73", "name": "游戏音乐榜", "bangid": "73"},
{"id": "tx__75", "name": "有声榜", "bangid": "75"},
{"id": "tx__131", "name": "校园音乐人排行榜", "bangid": "131"},
]
_PERIOD_LIST_RXP = re.compile(
r'<i class="play_cover__btn c_tx_link js_icon_play" data-listkey=".+?" data-listname=".+?" '
r'data-tid=".+?" data-date=".+?" .+?</i>'
)
_PERIOD_RXP = re.compile(r'data-listname="(.+?)" data-tid=".*?\/(.+?)" data-date="(.+?)" .+?</i>')
PERIOD_URL = "https://c.y.qq.com/node/pc/wk_v15/top.html"
# bangid → {"name", "bangid", "period"}(原 TS 的 periods 对象,模块级缓存)
_periods: dict[str, dict[str, str]] = {}
def filter_data(raw_list: Any) -> list[dict[str, Any]]:
songs: list[dict[str, Any]] = []
for item in raw_list or []:
file = (item or {}).get("file") or {}
types, _types = build_types(file)
album = item.get("album") or {}
album_name = album.get("name") or ""
album_mid = album.get("mid") or ""
singers = item.get("singer") or []
songs.append(
{
"singer": format_singer_name(singers, "name"),
"name": item.get("title"),
"albumName": album_name,
"albumId": album_mid,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album_mid,
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": song_img("" if album_name in ("", "空") else album_mid, singers),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return songs
async def _get_periods(bangid: str) -> str:
"""抓 top.html 填缓存,返回该榜单的 period;抓不到返回空串(不抛错,接口容忍)。"""
resp = await http_fetch(PERIOD_URL)
html = resp.body if isinstance(resp.body, str) else ""
for tag_html in _PERIOD_LIST_RXP.findall(html):
result = _PERIOD_RXP.search(tag_html)
if not result:
continue
_periods[result.group(2)] = {"name": result.group(1), "bangid": result.group(2), "period": result.group(3)}
info = _periods.get(bangid)
return info["period"] if info else ""
async def _list_detail_request(bangid: str, period: str, limit: int) -> dict[str, Any]:
# topid 必须发数字:传字符串 "4" 服务端会回 toplist.code 10006(无数据),传 4 才返回榜单。
topid: Any = int(bangid) if str(bangid).isdigit() else bangid
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": UA_MUSICU},
body={
"toplist": {
"module": "musicToplist.ToplistInfoServer",
"method": "GetDetail",
"param": {"topid": topid, "num": limit, "period": period},
},
"comm": {"uin": 0, "format": "json", "ct": 20, "cv": 1859},
},
)
return resp.body if isinstance(resp.body, dict) else {}
async def get_boards() -> dict[str, Any]:
return {"list": BOARD_LIST, "source": "tx"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
retry_num += 1
if retry_num > 3:
raise RuntimeError("try max num")
bare = str(bangid)
info = _periods.get(bare)
period = info["period"] if info else await _get_periods(bare)
body = await _list_detail_request(bare, period, LIMIT)
if body.get("code") != 0:
return await get_list(bangid, page, retry_num)
info_list = ((body.get("toplist") or {}).get("data") or {}).get("songInfoList") or []
return {
"total": len(info_list),
"list": filter_data(info_list),
"limit": LIMIT,
"page": 1,
"source": "tx",
}
+276
View File
@@ -0,0 +1,276 @@
"""QQ音乐歌词(移植自 sdk/tx/lyric.ts)。
上游返回 QRC(hex 密文),需 decode_qrc 解密后是逐字时间轴格式:
[起始毫秒,持续毫秒]字(起始,持续)字(起始,持续)...
同一份数据要拆成两路:
lyric —— 标准 LRC(把逐字标记剥掉,只留行时间)
yrc —— 逐字歌词(`<相对起始,持续>` 标记 + 文字)
翻译(trans)与罗马音(roma)的对齐由 fix*_time_tag 按 ±100ms 容差匹配行时间。
"""
from __future__ import annotations
import asyncio
import re
from collections.abc import Callable
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
from app.sdk.platforms.tx.qrc_decode import decode_qrc
from app.sdk.platforms.tx.utils import MUSICU_URL
SUCCESS_CODE = 0
_UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
_LINE_TIME2 = re.compile(r"^\[([\d:.]+)\]")
_WORD_TIME = re.compile(r"\(\d+,\d+\)")
_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+\))")
_WORD_TIME_PARTS = re.compile(r"\((\d+),(\d+)\)")
_TIME_SPLIT = re.compile(r"[:.]")
_TAG_HEAD = re.compile(r'^[\S\s]*?LyricContent="')
_TAG_TAIL = re.compile(r'"\/>[\S\s]*?$')
# songmid → songId 缓存;promises 用于合并并发请求(同一首歌并发取歌词只查一次详情)
_song_id_map: dict[str, Any] = {}
_promises: dict[str, asyncio.Task[Any]] = {}
def ms_format(time_ms: int) -> str:
"""毫秒 → `[mm:ss.SSS]`。"""
ms = time_ms % 1000
total = time_ms // 1000
minute = total // 60
second = total % 60
return f"[{minute:02d}:{second:02d}.{ms:03d}]"
def parse_lyric(lrc: str) -> dict[str, str]:
"""逐字 QRC 正文 → {lyric: 标准 LRC, yrc: 逐字歌词}。"""
text = lrc.strip().replace("\r", "")
if not text:
return {"lyric": "", "yrc": ""}
yrc_lines: list[str] = []
lrc_lines: list[str] = []
for raw_line in text.split("\n"):
line = raw_line.strip()
result = _LINE_TIME.search(line)
if not result:
if line.startswith("[offset"):
yrc_lines.append(line)
lrc_lines.append(line)
if _LINE_TIME2.match(line):
lrc_lines.append(line)
continue
start_ms = int(result.group(1))
start_str = ms_format(start_ms)
words = _LINE_TIME.sub("", line, count=1)
lrc_lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
times = _WORD_TIME_ALL.findall(words)
if not times:
continue
marks = []
for time in times:
parts = _WORD_TIME_PARTS.fullmatch(time)
if parts is None:
continue
marks.append(f"<{max(int(parts.group(1)) - start_ms, 0)},{parts.group(2)}>")
word_arr = _WORD_TIME.split(words)
new_words = "".join(
f"{mark}{word_arr[index] if index < len(word_arr) else ''}" for index, mark in enumerate(marks)
)
yrc_lines.append(f"{start_str}{new_words}")
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
def parse_rlyric(lrc: str) -> str:
"""罗马音(逐字格式)→ 标准 LRC 行。"""
text = lrc.strip().replace("\r", "")
if not text:
return ""
lines: list[str] = []
for raw_line in text.split("\n"):
line = raw_line.strip()
result = _LINE_TIME.search(line)
if not result:
continue
start_str = ms_format(int(result.group(1)))
words = _LINE_TIME.sub("", line, count=1)
lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
return "\n".join(lines)
def remove_tag(text: str) -> str:
"""剥离 `<LyricContent="..." />` 外层标签,只留正文。"""
return _TAG_TAIL.sub("", _TAG_HEAD.sub("", text))
def get_intv(interval: str) -> int:
"""`mm:ss.SSS` / `m:ss` → 毫秒(沿用原实现的 3600000 系数,两侧比较口径一致)。"""
if not interval:
return 0
if "." not in interval:
interval += ".0"
parts = _TIME_SPLIT.split(interval)
while len(parts) < 3:
parts.insert(0, "0")
minute, second, ms = parts[0], parts[1], parts[2]
def _int(value: str) -> int:
try:
return int(value)
except ValueError:
return 0
return _int(minute) * 3600000 + _int(second) * 1000 + _int(ms)
def _align_times(raw_lyric: str, lrc: str) -> str:
"""把 raw_lyric 的行时间替换成 lrc 中时间最近的标签(±100ms 内才算命中)。
与原文一致:lrc 行是一次性指针,未命中的行被丢弃(不做回补)。
"""
out: list[str] = []
lrc_lines = lrc.split("\n")
for line in raw_lyric.split("\n"):
result = _LINE_TIME2.match(line)
if not result:
continue
if not _LINE_TIME2.sub("", line, count=1).strip():
continue
t1 = get_intv(result.group(1))
while lrc_lines:
lrc_line = lrc_lines.pop(0)
lrc_result = _LINE_TIME2.match(lrc_line)
if not lrc_result:
continue
if abs(t1 - get_intv(lrc_result.group(1))) < 100:
out.append(_LINE_TIME2.sub(_literal(lrc_result.group(0)), line, count=1))
break
return "\n".join(out)
def _literal(text: str) -> Callable[[re.Match[str]], str]:
r"""替换串按字面量处理(`\` 与组引用需转义;时间标签本身不带这些字符,保持严格)。"""
return lambda _match: text
def fix_rlrc_time_tag(rlrc: str, lrc: str) -> str:
return _align_times(rlrc, lrc)
def fix_tlrc_time_tag(tlrc: str, lrc: str) -> str:
"""翻译行对齐。
原 TS 打算把 `mm:ss.d` 的小数位补齐到 3 位,但写的是
`time += ''.padStart(3 - n, '0')` —— 对空串补位恒为空,实际是空操作。
这里保持同样的换算口径(不补位),否则与 TS 版的 tlyric 对齐结果不一致。
"""
return _align_times(tlrc, lrc)
def parse(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
if lrc:
parsed = parse_lyric(remove_tag(lrc))
info["lyric"] = parsed["lyric"]
info["yrc"] = parsed["yrc"]
if rlrc:
info["rlyric"] = fix_rlrc_time_tag(parse_rlyric(remove_tag(rlrc)), info["lyric"])
if tlrc:
info["tlyric"] = fix_tlrc_time_tag(tlrc, info["lyric"])
return info
async def parse_lyric_hex(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
"""三路 QRC hex 解密后解析。"""
lyric, tlyric, rlyric = decode_qrc(lrc), decode_qrc(tlrc), decode_qrc(rlrc)
return parse(lyric, tlyric, rlyric)
async def get_song_id(song_info: dict[str, Any]) -> Any:
"""songId 优先;否则用 songmid 反查(带缓存 + 并发合并)。"""
song_id = song_info.get("songId")
if song_id:
return song_id
songmid = str(song_info.get("songmid") or "")
if not songmid:
raise RuntimeError("获取歌曲信息失败")
if songmid in _song_id_map:
return _song_id_map[songmid]
task = _promises.get(songmid)
if task is None:
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
_promises[songmid] = task
try:
info = await asyncio.shield(task)
finally:
_promises.pop(songmid, None)
if not info:
raise RuntimeError("获取歌曲信息失败")
_song_id_map[songmid] = info.get("songId")
return info.get("songId")
async def _fetch(song_id: Any, retry_num: int) -> dict[str, str]:
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"referer": "https://y.qq.com", "user-agent": _UA},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"method": "GetPlayLyricInfo",
"module": "music.musichallSong.PlayLyricInfo",
"param": {
"format": "json",
"crypt": 1,
"ct": 19,
"cv": 1873,
"interval": 0,
"lrc_t": 0,
"qrc": 1,
"qrc_t": 0,
"roma": 1,
"roma_t": 0,
"songID": song_id,
"trans": 1,
"trans_t": 0,
"type": -1,
},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
data = req.get("data") or {}
if body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
return await _retry(song_id, retry_num)
return await parse_lyric_hex(data.get("lyric"), data.get("trans"), data.get("roma"))
async def _retry(song_id: Any, retry_num: int) -> dict[str, str]:
"""失败重试(上限 3 次,文案与原文一致:Get lyric failed)。"""
retry_num += 1
if retry_num > 3:
raise RuntimeError("Get lyric failed")
# 原实现在此处把 songId 当 song_info 再走一次 getSongId(会解析成空 mid 而必然失败),
# 这里直接复用已取到的 songId 重发同一请求,降级链(同接口重试)保持一致。
return await _fetch(song_id, retry_num)
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
"""Platform.get_lyric 契约:song_info → {lyric, tlyric, rlyric, yrc}。"""
song_id = await get_song_id(song_info)
return await _fetch(song_id, 0)
+108
View File
@@ -0,0 +1,108 @@
"""QQ音乐歌曲信息(移植自 sdk/tx/musicInfo.ts)。
走 musicu.fcg 的 music.pf_song_detail_svr/get_song_detail_yqq:songmid → track_info。
歌词与评论模块靠它把 songmid 换成数字 songID(接口只认数字 id)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, size_formate
from app.sdk.platforms.tx.utils import MUSICU_URL, UA_MUSICU
def _build_types(file: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
"""file.size_* → types/_types(0 表示该档不存在,与原文一致)。"""
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for key, quality in (
("size_128mp3", "128k"),
("size_320mp3", "320k"),
("size_flac", "flac"),
("size_hires", "flac24bit"),
):
if file.get(key):
size = size_formate(file[key])
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
return types, _types
def _song_img(album_id: Any, singers: Any) -> str:
if album_id in ("", "空", None):
if isinstance(singers, list) and singers and singers[0].get("mid"):
return f"https://y.gtimg.cn/music/photo_new/T001R500x500M000{singers[0]['mid']}.jpg"
return ""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"
async def get_music_info_by_mid(songmid: str) -> dict[str, Any] | None:
"""songmid → SongInfo;无 media_mid 时返回 None(原文同样返回 null)。"""
resp = await http_fetch(
MUSICU_URL,
method="post",
headers={"User-Agent": UA_MUSICU},
body={
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
"req": {
"module": "music.pf_song_detail_svr",
"method": "get_song_detail_yqq",
"param": {"song_type": 0, "song_mid": songmid},
},
},
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if body.get("code") != 0 or req.get("code") != 0:
raise RuntimeError("获取歌曲信息失败")
item = (req.get("data") or {}).get("track_info") or {}
file = item.get("file") or {}
if not file.get("media_mid"):
return None
types, _types = _build_types(file)
album = item.get("album") or {}
album_name = album.get("name") or ""
album_id = album.get("mid") or ""
singers = item.get("singer") or []
return {
"singer": "、".join(str(singer.get("name")) for singer in singers if isinstance(singer, dict)),
"name": item.get("title"),
"albumName": album_name,
"albumId": album_id,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album.get("mid") or "",
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": _song_img(album_id, singers),
"types": types,
"_types": _types,
"typeUrl": {},
}
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
"""Platform.get_music_info 契约:song_info 里取 songmid。"""
songmid = song_info.get("songmid") or song_info.get("hash") or ""
if not songmid:
return None
return await get_music_info_by_mid(str(songmid))
async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""补全缺失的歌名/歌手/封面;取不到时原样返回(不抛错)。"""
if song_info.get("name") and song_info.get("singer"):
return song_info
try:
info = await get_music_info(song_info)
except (RuntimeError, OSError, TimeoutError):
return song_info
if not info:
return song_info
merged = {**info, **{k: v for k, v in song_info.items() if v}}
return merged
+139
View File
@@ -0,0 +1,139 @@
"""QQ音乐搜索(移植自 sdk/tx/musicSearch.ts)。
走 musics.fcg 签名通道(zzc 签名 + SearchCgiService/DoSearchForQQMusicMobile)。
item_song → SongInfo;无 media_mid 的条目直接丢弃(原文同样 skip)。
"""
from __future__ import annotations
import random
from typing import Any
from app.sdk.common.utils import format_play_time, format_singer_name
from app.sdk.platforms.tx.filter import build_types, song_img
from app.sdk.platforms.tx.utils import sign_request
DEFAULT_LIMIT = 50
SUCCESS_CODE = 0
# 客户端指纹参数,原样照抄(少一项上游会拒)
_SEARCH_COMM: dict[str, str] = {
"ct": "11",
"cv": "14090508",
"v": "14090508",
"tmeAppID": "qqmusic",
"phonetype": "EBG-AN10",
"deviceScore": "553.47",
"devicelevel": "50",
"newdevicelevel": "20",
"rom": "HuaWei/EMOTION/EmotionUI_14.2.0",
"os_ver": "12",
"OpenUDID": "0",
"OpenUDID2": "0",
"QIMEI36": "0",
"udid": "0",
"chid": "0",
"aid": "0",
"oaid": "0",
"taid": "0",
"tid": "0",
"wid": "0",
"uid": "0",
"sid": "0",
"modeSwitch": "6",
"teenMode": "0",
"ui_mode": "2",
"nettype": "1020",
"v4ip": "",
}
def _search_id() -> str:
"""对应原实现的 `Math.random().toString().slice(2)`:去掉 `0.` 前缀的随机数字串。"""
return str(random.random()).split(".", 1)[-1]
def handle_result(raw_list: Any) -> list[dict[str, Any]]:
if not raw_list or not isinstance(raw_list, list):
return []
songs: list[dict[str, Any]] = []
for item in raw_list:
file = (item or {}).get("file") or {}
if not file.get("media_mid"):
continue
types, _types = build_types(file)
album = item.get("album") or {}
album_mid = album.get("mid") or ""
singers = item.get("singer") or []
songs.append(
{
"singer": format_singer_name(singers, "name"),
"name": item.get("title"),
"albumName": album.get("name") or "",
"albumId": album_mid,
"source": "tx",
"interval": format_play_time(item.get("interval")),
"songId": item.get("id"),
"albumMid": album_mid,
"strMediaMid": file.get("media_mid"),
"songmid": item.get("mid"),
"img": song_img(album_mid, singers),
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return songs
async def _music_search(keyword: str, page: int, limit: int, retry_num: int) -> dict[str, Any]:
"""签名通道搜索。重试上限 5 次(文案与原文一致:搜索失败)。"""
if retry_num > 5:
raise RuntimeError("搜索失败")
resp = await sign_request(
{
"comm": _SEARCH_COMM,
"req": {
"module": "music.search.SearchCgiService",
"method": "DoSearchForQQMusicMobile",
"param": {
"search_type": 0,
"searchid": _search_id(),
"query": keyword,
"page_num": page,
"num_per_page": limit,
"highlight": 0,
"nqc_flag": 0,
"multi_zhida": 0,
"cat": 2,
"grp": 1,
"sin": 0,
"sem": 0,
},
},
}
)
body = resp.body if isinstance(resp.body, dict) else {}
req = body.get("req") or {}
if not req or body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
return await _music_search(keyword, page, limit, retry_num + 1)
return req.get("data") or {}
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
if not limit:
limit = DEFAULT_LIMIT
data = await _music_search(keyword, page, limit, retry_num)
# 响应结构:req.data.{body, meta}。歌曲数组在 data.body.item_song,总数在 data.meta.estimate_sum
# (TS 侧写的是 body.item_song / meta.estimate_sum,其中 body 即此处的 data)。
inner = data.get("body")
meta = data.get("meta")
songs = handle_result(inner.get("item_song") if isinstance(inner, dict) else None)
total = (meta.get("estimate_sum") or 0) if isinstance(meta, dict) else 0
return {
"list": songs,
"allPage": -(-int(total) // limit) if total else 0, # ceil 除法
"limit": limit,
"total": total,
"source": "tx",
}
+153
View File
@@ -0,0 +1,153 @@
"""QQ音乐链接解析(移植自 sdk/tx/parse.ts)。
歌曲链接形态:
* https://y.qq.com/n/ryqq/songDetail/<songmid>
* https://y.qq.com/n/yqq/song/<songmid>.html
* https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
* ...?songmid=<songmid>
元数据走 fcg_play_single_song(顺带拿 file 体积信息)。直链 url/type 是尽力附带:
取流失败只返回元数据,不影响解析结果(与上游一致,VIP/下架曲目即如此)。
"""
from __future__ import annotations
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
from app.sdk.platforms.tx.filter import album_img
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
)
_SONG_DETAIL_RE = re.compile(r"songDetail/([A-Za-z0-9]+)")
_SONG_PATH_RE = re.compile(r"/song/([A-Za-z0-9]+)")
_SONG_MID_QUERY_RE = re.compile(r"[?&]songmid=([A-Za-z0-9]+)")
_SONG_ID_QUERY_RE = re.compile(r"[?&]songid=(\d+)")
_DETAIL_URL = "https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg"
async def _fetch_single_song(query: str) -> dict[str, Any] | None:
"""fcg_play_single_song 首条(query 形如 songmid=xxx / songid=123)。"""
resp = await http_fetch(
f"{_DETAIL_URL}?{query}&format=json",
headers={"User-Agent": _UA, "Referer": "http://m.y.qq.com"},
timeout=15000,
)
if resp.status_code != 200:
return None
body = resp.body if isinstance(resp.body, dict) else {}
data = body.get("data")
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
return None
return data[0]
async def fetch_mid_by_song_id(songid: str) -> str:
"""上游 fetchSongDetailByID:数字 songid → mid。"""
item = await _fetch_single_song(f"songid={quote(str(songid), safe='')}")
mid = str((item or {}).get("mid") or "")
if not mid:
raise RuntimeError("QQ音乐歌曲不存在")
return mid
async def fetch_song_detail_raw(songmid: str) -> dict[str, Any]:
"""上游 fetchSongDetail:songmid → 原始条目。"""
item = await _fetch_single_song(f"songmid={quote(str(songmid), safe='')}")
if not item or not item.get("mid"):
raise RuntimeError("QQ音乐歌曲不存在")
return item
def convert_song(item: dict[str, Any]) -> dict[str, Any]:
"""原始条目 → MusicServer Song 形状。
注意体积字段的判定与 filter.build_types 不同:这里要求 `isinstance(number)` 且 > 0
(原文是 `typeof size === 'number' && size > 0`),因此字符串型体积会被跳过。
"""
file = item.get("file") or {}
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for key, quality in (
("size_128mp3", "128k"),
("size_320mp3", "320k"),
("size_flac", "flac"),
("size_hires", "flac24bit"),
):
size = file.get(key)
if isinstance(size, (int, float)) and not isinstance(size, bool) and size > 0:
formatted = size_formate(size)
types.append({"type": quality, "size": formatted})
_types[quality] = {"size": formatted}
album = item.get("album") or {}
album_mid = album.get("mid") or ""
return {
"source": "tx",
"songmid": str(item.get("mid")),
"songId": item.get("id"),
"name": item.get("title") or item.get("name") or "",
"singer": format_singer_name(item.get("singer"), "name"),
"albumId": album_mid,
"albumMid": album_mid,
"albumName": album.get("name") or "",
"interval": format_play_time(item.get("interval") or 0),
"img": album_img(album_mid) if album_mid else "",
"lrc": None,
"strMediaMid": file.get("media_mid") or item.get("mid"),
"types": types,
"_types": _types,
"typeUrl": {},
}
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → Song(尽力附带直链 url/type)。"""
raw = str(link or "")
match = _SONG_DETAIL_RE.search(raw) or _SONG_PATH_RE.search(raw)
songmid = match.group(1) if match else ""
songid = ""
if not songmid:
id_match = _SONG_ID_QUERY_RE.search(raw)
songid = id_match.group(1) if id_match else ""
mid_match = _SONG_MID_QUERY_RE.search(raw)
songmid = mid_match.group(1) if mid_match else ""
if not songmid and songid:
songmid = await fetch_mid_by_song_id(songid)
if not songmid:
raise RuntimeError("无效的QQ音乐歌曲链接")
item = await fetch_song_detail_raw(songmid)
song = convert_song(item)
if songid:
song["songId"] = int(songid)
try:
# 延迟导入:apis.tx 反向依赖 platforms.tx.utils,模块级互 import 会拿到半初始化模块
from app.sdk.apis import tx as tx_api
hit = await tx_api.get_music_url(song, "320k")
song["url"] = hit.get("url")
song["type"] = hit.get("type")
except (RuntimeError, OSError, TimeoutError):
# 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
from app.sdk.platforms.tx.album import parse_album_link
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
from app.sdk.platforms.tx.playlist import parse_playlist_link
return await parse_playlist_link(link)
+14
View File
@@ -0,0 +1,14 @@
"""QQ音乐封面(移植自 sdk/tx/index.ts 的 getPic)。
封面是固定 URL 规则:T002 = 专辑图,按 albumId/albumMid 拼。路由总是传 albumId(缺省空串),
这里对 None 也按空串处理(原 JS 会把 undefined 拼成字面量 "undefined",无实际意义)。
"""
from __future__ import annotations
from typing import Any
async def get_pic(song_info: dict[str, Any]) -> str:
album_id = song_info.get("albumId") or ""
return f"https://y.gtimg.cn/music/photo_new/T002R500x500M000{album_id}.jpg"
Loaded 100 of 182 files, more files were not shown because too many files have changed in this diff. Show more