Files
MusicServer/docs/PORTING.md
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

11 KiB
Raw Blame History

平台移植契约

本文件是 TypeScript → Python 迁移的唯一契约来源。每个平台包都必须按此实现, 不要把 TS 机械直译:各平台方法签名差异很大(见「签名差异」一节),逐个核对。

1. 目录与命名

TypeScript Python
sdk/kw/index.ts app/sdk/platforms/kw/__init__.py
sdk/kw/musicSearch.ts app/sdk/platforms/kw/music_search.py
sdk/kw/hotSearch.ts app/sdk/platforms/kw/hot_search.py
sdk/kw/songList.ts app/sdk/platforms/kw/song_list.py
sdk/apis/kw.ts app/sdk/apis/kw.py
sdk/5sing/* app/sdk/platforms/fivesing/*
sdk/auth/wy.ts app/sdk/auth/wy.py
sdk/bili/login.ts app/sdk/auth/bili.py

命名规则:文件名/属性名 camelCase → snake_case;类名保持 PascalCase。 平台 id 对外仍是两字母短码(kw kg tx wy mg bd joox bili 5sing apple + 有声 xm me qt lr), 包目录名用 fivesing(Python 标识符不能以数字开头),但 MODULE.id 必须是 "5sing"。

2. 平台模块入口

每个 app/sdk/platforms/<pkg>/__init__.py 导出 MODULE:

from app.sdk.platforms._base import Platform
from app.sdk.platforms.kw import album, hot_search, leaderboard, lyric, music_search, pic, playlist, song_list

MODULE = Platform(
    id="kw",
    name="酷我音乐",
    music_search=music_search,      # 子模块对象(有 .search 方法)
    leaderboard=leaderboard,        # .get_boards() / .get_list(bangid, page)
    song_list=song_list,            # .get_list(sort_id, tag_id, page) / .sort_list
    hot_search=hot_search,          # .get_list()
    album=album,
    playlist=playlist,
    get_lyric=lyric.get_lyric,      # 顶层方法直接挂函数
    get_pic=pic.get_pic,
)

get_music_url 不要挂在平台模块上,它属于 app/sdk/apis/<pkg>.py; app/sdk/__init__.py 的 _quality_table() 从 apis 模块读 SUPPORT_QUALITYS。 若平台需要对外暴露 get_music_url(如 /sources 的 url 能力位), 在 Platform(...) 里传 get_music_url=apis.get_music_url。

缺省即不支持:没有的成员不要写 None 占位,直接省略。/sources 与路由 404 都据此判断。

3. 子模块协议

# music_search
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict
# → {"list": [SongInfo, ...], "total": int|None, "limit": int, "source": str, "allPage": int|None}

# leaderboard
async def get_boards() -> dict            # → {"list": [{"id", "name", "bangid"}]}
async def get_list(bangid: str, page: int = 1) -> dict
# → {"list": [SongInfo], "total": int|str|None, "page": int, "limit": int, "source": str}

# song_list
async def get_list(sort_id, tag_id, page: int = 1) -> dict
sort_list: list[dict]                     # 可选:[{"id","name"}],路由取首项作默认排序

# hot_search
async def get_list() -> dict              # → {"list": [str], "source": str}

# singer
async def get_info(singer_id: str) -> dict
async def get_song_list(singer_id: str, page: int = 1, limit: int = 20) -> dict
async def get_album_list(singer_id: str, page: int = 1, limit: int = 20) -> dict

# album
async def get_album_detail(album_id: str, page: int = 1, limit: int = 20) -> dict   # 优先
async def get_album_list_detail(album_id: str, page: int = 1) -> dict              # kw 用这个(第 2 参是页码,第 3 参是重试次数,不要透传 limit)
async def search_album(keyword: str, page: int = 1, limit: int = 20) -> dict | list

# playlist
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 20) -> dict
async def get_playlist_songs(playlist_id: str) -> list                             # 可选,详情签名不支持分页时的兜底
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict | list
async def get_categories() -> dict | list
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict | list

# parse
async def parse_song(link: str) -> dict
async def parse_album(link: str) -> dict
async def parse_playlist(link: str) -> dict

# user_playlist
async def get_list(page: int = 1, limit: int = 20) -> dict | list

返回裸数组也可以(路由会把 list 与 total 补齐),但能返回完整结构就返回完整结构。

4. 顶层方法

async def get_music_url(song_info: dict, quality: str, retry_num: int = 0) -> dict
# → {"type": <实际命中音质>, "url": <播放地址>}
# 可选附加:{"encrypted": True, "fileKey": "..."} —— mg 的 Z3D/I3D 加密流

async def get_lyric(song_info: dict) -> dict     # → {"lyric", "tlyric", "rlyric"?, "yrc"?}
async def get_pic(song_info: dict) -> str        # → 封面 URL
async def get_music_info(song_info: dict) -> dict        # 可选
async def handle_music_info(song_info: dict) -> dict     # 可选:补全 name/singer/img/albumName

song_info 是普通 dict,路由传入的关键字段: {"songmid": id, "hash": id, "copyrightId": id, "albumId": album_id or "", "name": ..., "singer": ..., "interval": ..., "_types": {}}。 平台按需读取,缺失就用空值兜底,不要假设字段一定存在(原 TS 同样如此)。

5. 请求层用法

from app.sdk.common.request import http_fetch

resp = await http_fetch(url, headers={...})                  # 默认 GET
resp = await http_fetch(url, method="POST", form={...})      # urlencoded
resp = await http_fetch(url, method="POST", body={...})      # JSON
resp = await http_fetch(url, timeout=10_000)                 # 毫秒,与原文一致

resp.status_code   # int(Node 的 statusCode)
resp.body          # 已解析:JSON → dict/list,否则 str
resp.raw           # 原始 bytes
resp.header("set-cookie")

与 TS 的差异(迁移时必改):

TS 写法 Python 写法
const { promise, cancelHttp } = httpFetch(...) await promise await http_fetch(...)
.then(({ body }) => ...) resp = await http_fetch(...); 用 resp.body
new Promise(...) 包一层 直接 async def
Promise.all(tasks) await asyncio.gather(*tasks)
Promise.allSettled asyncio.gather(..., return_exceptions=True)
setTimeout await asyncio.sleep(ms / 1000)
Buffer.from(x, 'hex') bytes.fromhex(x)
Buffer.from(x).toString('base64') base64.b64encode(x).decode()
crypto.createHash('md5') hashlib.md5(...).hexdigest()
crypto.createHmac('sha256', k) hmac.new(k.encode(), msg.encode(), hashlib.sha256)
crypto.createDecipheriv('aes-128-ecb') from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
crypto.randomUUID() uuid.uuid4().hex / str(uuid.uuid4())
zlib.inflateSync/inflateRawSync zlib.decompress(data, 15 + 32) / zlib.decompress(data, -15)
iconv-lite.decode(buf, 'gbk') buf.decode('gbk', errors='replace')
qrcode 生成二维码 app/sdk/common/qrcode.py 的 to_data_url(text)
Date.now() int(time.time() * 1000)
Math.random() random.random()
JSON.stringify(x) json.dumps(x, ensure_ascii=False, separators=(",", ":"))
Object.keys(a).sort().join('&') "&".join(f"{k}={v}" for k, v in sorted(a.items()))

Node 内置模块只有这四处,其余平台不需要额外依赖: crypto(apis/kw.ts、kw/util.ts、wy/utils/crypto.ts、sdk/utils.ts)、 zlib(tx/qrcDecode.ts)、iconv-lite(kw/lyricDecode.ts)、qrcode(bili/login.ts)。

6. 工具函数

app/sdk/common/utils.py:size_formate / format_play_time / date_format / date_format2 / format_play_count / decode_name / format_singer_name / pick / to_md5。

平台模块里 import { sizeFormate } from '../common/utils.ts' 对应:

from app.sdk.common.utils import size_formate

sdk/utils.ts 里的 formatSinger 对应 format_singer_name(签名:(singers, name_key="name", join="、"))。

7. 凭据

from app.sdk.auth_store import get_credentials, set_credentials, get_kg_auth, get_tx_auth, get_cookie

get_cookie("xm")        # 空字符串表示未登录
get_kg_auth()           # {"token", "userid"} 或 None
get_tx_auth()           # {"cookie", "uin"} 或 None

8. 错误

平台内部抛 RuntimeError("上游返回: ...") 即可,路由层统一转 502。 不要抛 ApiError(那是路由层的),除非确需指定状态码(如登录失效 401)。

平台内部对「可降级的失败」(换音质档、换接口、换 CDN)必须自己 catch 后重试, 不要让它冒泡成 502 —— 与原始实现的降级链一致。

9. 签名差异(逐个核对,别批量替换)

平台 取流 id 特别之处
kw songmid apis/kw.py 双通道:mobi.s(主,需校验 bitrate 精确命中 + 公开格式白名单)→ web playUrl(需动态 cookie 算 Secret 头)。CDN 返回的 query 用 $ 顶替 =,必须还原。
kg hash 取流需 encode_album_audio_id;有 login.token 时走 VIP 通道。歌词是 KRC 需解密(krcDecode.ts)。
tx songmid → 需先查 songmid → media_mid 音质候选表 + 登录态分支;歌词 QRC 需解密(qrcDecode.ts,zlib inflate)。
wy songmid 取流需 eapi 加密(wy/utils/crypto.ts);未登录走匿名接口。
mg copyrightId 加密流:返回 {"encrypted": True, "fileKey": ...},路由转 /stream/mg/<票据>。解密逐字节 plain[i] = cipher[i] - key[i % 32]。
bd songmid 多 CDN 轮询(失败换下一个)。
joox songmid 需 JOOX 客户端签名头。
bili bvid / aid 取流需 cid,且必须带 Referer/UA。音质档位是 qn 整数(不是字符串)。
5sing songId + songType songType:fc=翻唱、yc=原唱、bz=伴奏。音质是 sq/hq/lq 不是 flac/320k/128k。
apple songmid 只有 preview 试听片段(完整曲 DRM)。
xm trackId 需设备指纹 + 签名(xm/sign.ts)。付费内容需 cookie。
me soundId App API 签名(me/sign.ts),cookie 里的 token 自动带入签名。
qt channelId:programId 复合 id 见 /album/qt 返回的 id。
lr bookId:section 复合 id 见 /album/lr 返回的 id。必须登录态,免费章节也需要。

10. 验收要求

每个平台移植完成后,必须真实联网验证(不靠 mock):

uv run python -c "
import asyncio
from app.sdk.platforms.kw import MODULE
async def main():
    r = await MODULE.music_search.search('稻香', 1, 5)
    print(len(r['list']), r['list'][0])
asyncio.run(main())
"

至少覆盖:搜索 → 取流 → 歌词 → 封面;有榜单/歌单/专辑能力的再各验一次。 取流验证要打印 URL 并实际 GET 前 4 字节确认是音频(ID3/fLaC/OggS/ftyp)。 上游接口失效时如实报告并写进注释,不要用假数据强行通过。

11. 规范

改完必须两条都过:

uv run ruff check app && uv run ruff format --check app
uv run pyright app/sdk/platforms/<pkg> app/sdk/apis/<pkg>.py

ruff 规则启用 E,F,W,I,UP,B,ASYNC,RUF,行宽 120,双引号,target-version = py312。 注释用中文,与既有代码风格一致;只保留解释「为什么」的注释,不复述代码。