Files
MusicServer/docs/PORTING.md
T
snowy-yang 0df18f048b fix(server): 修复路由遮蔽、流式背压与鉴权限流顺序
- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
2026-10-23 23:40:55 +08:00

240 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 平台移植契约
本文件是 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`:
```python
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. 子模块协议
```python
# 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. 顶层方法
```python
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. 请求层用法
```python
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'` 对应:
```python
from app.sdk.common.utils import size_formate
```
`sdk/utils.ts` 里的 `formatSinger` 对应 `format_singer_name`(签名:`(singers, name_key="name", join="、")`)。
## 7. 凭据
```python
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):
```bash
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. 规范
改完必须两条都过:
```bash
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`。
注释用中文,与既有代码风格一致;只保留解释「为什么」的注释,不复述代码。