feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+267
View File
@@ -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