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