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,267 @@
|
||||
"""SDK 聚合入口 —— 平台注册表、跨平台搜索与精确匹配。
|
||||
|
||||
对应原 sdk/index.ts。平台 id 对外为短码(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple),
|
||||
sdk 目录与导入名使用平台名(bd → qian 目录历史已合并,见下)。
|
||||
|
||||
各平台包导出 `MODULE`(PlatformModule 实例),能力位按 snake_case 命名:
|
||||
music_search / leaderboard / song_list / hot_search / comment / singer /
|
||||
album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import decode_name
|
||||
|
||||
# 通用工具函数(平台模块 `from app.sdk import format_play_time` 的解析目标)
|
||||
__all__ = [
|
||||
"SOURCE_MODULES",
|
||||
"SUPPORTED_SOURCES",
|
||||
"date_format",
|
||||
"date_format2",
|
||||
"decode_name",
|
||||
"find_music",
|
||||
"format_play_count",
|
||||
"format_play_time",
|
||||
"init",
|
||||
"search_music",
|
||||
"size_formate",
|
||||
"sources",
|
||||
"support_quality",
|
||||
]
|
||||
|
||||
from app.sdk.common.utils import (
|
||||
date_format,
|
||||
date_format2,
|
||||
format_play_count,
|
||||
format_play_time,
|
||||
size_formate,
|
||||
)
|
||||
|
||||
_SINGERS_SEPARATOR = re.compile(r"、|&|;|;|/|,|,|\||\]|\[|!|!")
|
||||
|
||||
|
||||
def _load_platforms() -> list[dict[str, Any]]:
|
||||
"""按显示顺序加载平台包。导入失败即启动失败(与原 TS 静态导入一致)。"""
|
||||
from app.sdk.platforms import apple, bd, bili, fivesing, joox, kg, kw, lr, me, mg, qt, tx, wy, xm
|
||||
|
||||
# no_aggregate:有声/播客类平台不参与聚合搜索与跨平台匹配,仅支持 source 直查
|
||||
return [
|
||||
{"id": "kw", "name": "酷我音乐", "module": kw.MODULE},
|
||||
{"id": "kg", "name": "酷狗音乐", "module": kg.MODULE},
|
||||
{"id": "tx", "name": "QQ音乐", "module": tx.MODULE},
|
||||
{"id": "wy", "name": "网易音乐", "module": wy.MODULE},
|
||||
{"id": "xm", "name": "喜马拉雅", "module": xm.MODULE, "no_aggregate": True},
|
||||
{"id": "me", "name": "猫耳FM", "module": me.MODULE, "no_aggregate": True},
|
||||
{"id": "qt", "name": "蜻蜓FM", "module": qt.MODULE, "no_aggregate": True},
|
||||
{"id": "lr", "name": "懒人听书", "module": lr.MODULE, "no_aggregate": True},
|
||||
{"id": "mg", "name": "咪咕音乐", "module": mg.MODULE},
|
||||
{"id": "bd", "name": "千千音乐", "module": bd.MODULE},
|
||||
{"id": "joox", "name": "JOOX", "module": joox.MODULE},
|
||||
{"id": "bili", "name": "哔哩哔哩", "module": bili.MODULE},
|
||||
{"id": "5sing", "name": "5sing", "module": fivesing.MODULE},
|
||||
# Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
|
||||
{"id": "apple", "name": "Apple Music", "module": apple.MODULE, "no_aggregate": True},
|
||||
]
|
||||
|
||||
|
||||
SUPPORTED_SOURCES: list[dict[str, Any]] = _load_platforms()
|
||||
SOURCE_MODULES: dict[str, Any] = {item["id"]: item["module"] for item in SUPPORTED_SOURCES}
|
||||
|
||||
sources: list[dict[str, Any]] = [
|
||||
{"id": item["id"], "name": item["name"], **({"noAggregate": True} if item.get("no_aggregate") else {})}
|
||||
for item in SUPPORTED_SOURCES
|
||||
]
|
||||
|
||||
|
||||
def _quality_table() -> dict[str, list[str]]:
|
||||
from app.sdk.apis import apple as apple_api
|
||||
from app.sdk.apis import bd as bd_api
|
||||
from app.sdk.apis import bili as bili_api
|
||||
from app.sdk.apis import fivesing as fivesing_api
|
||||
from app.sdk.apis import joox as joox_api
|
||||
from app.sdk.apis import kg as kg_api
|
||||
from app.sdk.apis import kw as kw_api
|
||||
from app.sdk.apis import lr as lr_api
|
||||
from app.sdk.apis import me as me_api
|
||||
from app.sdk.apis import mg as mg_api
|
||||
from app.sdk.apis import qt as qt_api
|
||||
from app.sdk.apis import tx as tx_api
|
||||
from app.sdk.apis import wy as wy_api
|
||||
from app.sdk.apis import xm as xm_api
|
||||
|
||||
return {
|
||||
"kw": kw_api.SUPPORT_QUALITYS,
|
||||
"kg": kg_api.SUPPORT_QUALITYS,
|
||||
"tx": tx_api.SUPPORT_QUALITYS,
|
||||
"wy": wy_api.SUPPORT_QUALITYS,
|
||||
"xm": xm_api.SUPPORT_QUALITYS,
|
||||
"me": me_api.SUPPORT_QUALITYS,
|
||||
"qt": qt_api.SUPPORT_QUALITYS,
|
||||
"lr": lr_api.SUPPORT_QUALITYS,
|
||||
"mg": mg_api.SUPPORT_QUALITYS,
|
||||
"bd": bd_api.SUPPORT_QUALITYS,
|
||||
"joox": joox_api.SUPPORT_QUALITYS,
|
||||
"bili": bili_api.SUPPORT_QUALITYS,
|
||||
"5sing": fivesing_api.SUPPORT_QUALITYS,
|
||||
"apple": apple_api.SUPPORT_QUALITYS,
|
||||
}
|
||||
|
||||
|
||||
support_quality: dict[str, list[str]] = _quality_table()
|
||||
|
||||
|
||||
async def init() -> None:
|
||||
"""调用各平台的 init 钩子(无 init 的跳过;单个失败不阻塞启动,与原 init 一致)。"""
|
||||
for item in SUPPORTED_SOURCES:
|
||||
hook = getattr(item["module"], "init", None)
|
||||
if hook is None:
|
||||
continue
|
||||
try:
|
||||
await hook()
|
||||
except Exception as err: # noqa: BLE001
|
||||
print(f"[sdk] {item['id']} init 失败(已跳过): {err}")
|
||||
|
||||
|
||||
def _trim_str(value: Any) -> Any:
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _filter_str(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return re.sub(r"""\s|'|\.|,|,|&|"|、|\(|\)|(|)|`|~|-|<|>|\||/|\]|\[|!|!""", "", value)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _get_interval_seconds(interval: Any) -> int:
|
||||
if not interval:
|
||||
return 0
|
||||
parts = str(interval).split(":")
|
||||
seconds = 0
|
||||
unit = 1
|
||||
while parts:
|
||||
try:
|
||||
seconds += int(parts.pop()) * unit
|
||||
except ValueError:
|
||||
pass
|
||||
unit *= 60
|
||||
return seconds
|
||||
|
||||
|
||||
def _sort_single(singer: Any) -> str:
|
||||
if not singer:
|
||||
return ""
|
||||
text = str(singer)
|
||||
if _SINGERS_SEPARATOR.search(text):
|
||||
return "、".join(sorted(_SINGERS_SEPARATOR.split(text)))
|
||||
return text
|
||||
|
||||
|
||||
async def search_music(
|
||||
*,
|
||||
name: str,
|
||||
singer: str | None = None,
|
||||
source: str | None = None,
|
||||
only: str | None = None,
|
||||
limit: int = 25,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""多平台聚合搜索。
|
||||
|
||||
name 歌名;singer 歌手;source 指定平台时聚合结果跳过该平台;
|
||||
only 只搜该平台(与 source 互斥,优先生效);limit 单平台结果条数。
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
music_name = _trim_str(name)
|
||||
tasks = []
|
||||
for item in SUPPORTED_SOURCES:
|
||||
module = item["module"]
|
||||
if getattr(module, "music_search", None) is None:
|
||||
continue
|
||||
# 有声平台(no_aggregate)仅在显式 only 指定时参与搜索
|
||||
if item.get("no_aggregate") and item["id"] != only:
|
||||
continue
|
||||
if only:
|
||||
if item["id"] != only:
|
||||
continue
|
||||
elif item["id"] == source:
|
||||
continue
|
||||
keyword = f"{music_name} {singer or ''}".strip()
|
||||
tasks.append(_search_one(module, keyword, limit))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
return [item for item in results if item]
|
||||
|
||||
|
||||
async def _search_one(module: Any, keyword: str, limit: int) -> dict[str, Any] | None:
|
||||
try:
|
||||
return await module.music_search.search(keyword, 1, limit)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
async def find_music(
|
||||
*,
|
||||
name: str,
|
||||
singer: str | None = None,
|
||||
album_name: str | None = None,
|
||||
interval: str | None = None,
|
||||
source: str | None = None,
|
||||
only: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)。"""
|
||||
lists = await search_music(name=name, singer=singer, source=source, only=only, limit=25)
|
||||
|
||||
f_music_name = _filter_str(name).lower()
|
||||
f_singer = _filter_str(_sort_single(singer)).lower()
|
||||
f_album_name = _filter_str(album_name).lower()
|
||||
f_interval = _get_interval_seconds(interval)
|
||||
|
||||
def is_equals_interval(value: int) -> bool:
|
||||
return abs((f_interval or value) - (value or f_interval)) < 5
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for result in lists:
|
||||
for item in result.get("list") or []:
|
||||
item["name"] = _trim_str(item.get("name"))
|
||||
item["singer"] = _trim_str(item.get("singer"))
|
||||
item["_f_singer"] = _filter_str(_sort_single(item.get("singer")).lower())
|
||||
item["_f_music_name"] = _filter_str(str(item.get("name") or "").lower())
|
||||
item["_f_album_name"] = _filter_str(str(item.get("albumName") or "").lower())
|
||||
item["_f_interval"] = _get_interval_seconds(item.get("interval"))
|
||||
if not is_equals_interval(item["_f_interval"]):
|
||||
item["name"] = None
|
||||
continue
|
||||
candidates.append(item)
|
||||
|
||||
matched_order: list[dict[str, Any]] = []
|
||||
|
||||
def take(predicate) -> None:
|
||||
nonlocal candidates
|
||||
hit = [item for item in candidates if predicate(item)]
|
||||
rest = [item for item in candidates if not predicate(item)]
|
||||
matched_order.extend(hit)
|
||||
candidates = rest
|
||||
|
||||
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name and i.get("interval") == interval)
|
||||
take(
|
||||
lambda i: (
|
||||
i["_f_music_name"] == f_music_name and i["_f_singer"] == f_singer and i["_f_album_name"] == f_album_name
|
||||
)
|
||||
)
|
||||
take(lambda i: i["_f_singer"] == f_singer and i["_f_music_name"] == f_music_name)
|
||||
take(lambda i: i["_f_music_name"] == f_music_name and i.get("interval") == interval)
|
||||
take(lambda i: i["_f_singer"] == f_singer and i.get("interval") == interval)
|
||||
take(lambda i: i.get("interval") == interval)
|
||||
take(lambda i: i["_f_music_name"] == f_music_name)
|
||||
take(lambda i: i["_f_singer"] == f_singer)
|
||||
take(lambda i: i["_f_album_name"] == f_album_name)
|
||||
|
||||
for item in [*candidates, *matched_order]:
|
||||
for key in ("_f_singer", "_f_music_name", "_f_album_name", "_f_interval"):
|
||||
item.pop(key, None)
|
||||
|
||||
matched_order.extend(candidates)
|
||||
return matched_order
|
||||
Reference in new issue
Block a user