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,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 页面重定向次数过多")
|
||||
Reference in new issue
Block a user