refactor(server): 迁移到 Fastify 5 + 完善文档与路由参数适配
This commit is contained in:
1 parent
59b2aae584
commit
2b4bb16e02
16 files changed
+552
-1186
No files matched your search
@@ -7,6 +7,7 @@ pnpm-lock.yaml
|
|||||||
# Runtime
|
# Runtime
|
||||||
*.log
|
*.log
|
||||||
logs/
|
logs/
|
||||||
|
tmp/
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
@@ -15,6 +16,9 @@ logs/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# ZCode 本地目录
|
||||||
|
.zcode/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|||||||
@@ -12,26 +12,30 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm start # 默认端口 3300,可用 PORT 环境变量修改
|
npm start # 默认端口 16688,可用 PORT 环境变量修改
|
||||||
npm test # 运行冒烟测试(自动启动/关闭本地服务器)
|
npm test # 运行冒烟测试(自动启动/关闭本地服务器)
|
||||||
```
|
```
|
||||||
|
|
||||||
## HTTP 接口
|
## HTTP 接口
|
||||||
|
|
||||||
|
规范风格使用路径参数;旧版 query 风格(如 `/url?source=kw&id=228908`)保留兼容,已标记 deprecated。
|
||||||
|
|
||||||
| 接口 | 说明 |
|
| 接口 | 说明 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) |
|
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) |
|
||||||
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) |
|
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) |
|
||||||
| `GET /url?source=kw&id=228908&quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) |
|
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) |
|
||||||
| `GET /lyric?source=kw&id=228908` | 获取歌词(返回 lyric/tlyric/lxlyric) |
|
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/lxlyric) |
|
||||||
| `GET /pic?source=kw&id=228908` | 获取封面 |
|
| `GET /pic/:source/:id` | 获取封面 |
|
||||||
| `GET /boards?source=kw` | 榜单列表 |
|
| `GET /boards/:source` | 榜单列表 |
|
||||||
| `GET /board?source=kw&id=93&page=1` | 榜单详情 |
|
| `GET /boards/:source/:boardId` | 榜单详情 |
|
||||||
| `GET /hotSearch?source=kw` | 热搜列表 |
|
| `GET /hot-search/:source` | 热搜列表 |
|
||||||
| `GET /songList?source=kw&page=1` | 歌单列表 |
|
| `GET /song-list/:source?page=1` | 歌单列表 |
|
||||||
|
| `GET /sources` | 平台列表(音质与能力) |
|
||||||
| `GET /health` | 健康检查 |
|
| `GET /health` | 健康检查 |
|
||||||
|
| `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) |
|
||||||
|
|
||||||
统一返回格式:`{ code: 0, data: ... }` 成功;`{ code: 非0, msg: ... }` 失败。
|
统一返回格式:`{ code: 0, data: ... }` 成功;`{ code: 非0, msg: ... }` 失败。状态码语义:422 参数校验失败、404 接口或平台不存在、405 方法不允许(带 Allow 头)、502 上游平台失败。
|
||||||
|
|
||||||
所有接口默认返回 `Access-Control-Allow-Origin: *`,可直接在浏览器/前端调用。
|
所有接口默认返回 `Access-Control-Allow-Origin: *`,可直接在浏览器/前端调用。
|
||||||
|
|
||||||
@@ -45,10 +49,18 @@ npm test # 运行冒烟测试(自动启动/关闭本地服务器)
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
server.js HTTP 服务器入口(npm start 实际启动文件)
|
server.js HTTP 服务器入口(npm start 实际启动文件,Fastify)
|
||||||
index.js SDK 根模块(工具函数聚合导出)
|
routes.js 业务路由(JSON Schema 校验 + OpenAPI 元数据)
|
||||||
smoke-test.js 冒烟测试(自动启动本地服务器)
|
smoke-test.js 冒烟测试(自动启动本地服务器)
|
||||||
|
index.js SDK 根模块(工具函数聚合导出)
|
||||||
|
http/ HTTP 层
|
||||||
|
api-error.js 业务错误类型(404/502 等 → 统一 JSON 响应)
|
||||||
|
openapi/ 文档层
|
||||||
|
components.js OpenAPI 共享 schema(与路由校验同源)
|
||||||
|
ui.js ReDoc 页面模板
|
||||||
apis/ 各平台 URL 直连实现(kw/kg/tx/wy/mg/bd)
|
apis/ 各平台 URL 直连实现(kw/kg/tx/wy/mg/bd)
|
||||||
|
lx-source.js lx 聚合源懒加载入口
|
||||||
|
lx-source-ivm.js isolated-vm 隔离执行器
|
||||||
common/ 公共模块
|
common/ 公共模块
|
||||||
request.js HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
|
request.js HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
|
||||||
utils.js 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
|
utils.js 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
|
||||||
@@ -58,17 +70,17 @@ src/
|
|||||||
lyricDecode.js 酷我/QQ 歌词解码(替代原 Electron 主进程处理器)
|
lyricDecode.js 酷我/QQ 歌词解码(替代原 Electron 主进程处理器)
|
||||||
qrcDecode.js QQ 歌词 3DES 解密(纯 JS 移植)
|
qrcDecode.js QQ 歌词 3DES 解密(纯 JS 移植)
|
||||||
lyricUtils/kg.js 酷狗 krc 歌词解码
|
lyricUtils/kg.js 酷狗 krc 歌词解码
|
||||||
musicSdk/ 洛雪音乐聚合 SDK(搜索/歌词/榜单等核心逻辑)
|
musicSdk/ 洛雪音乐聚合 SDK(搜索/歌词/榜单等核心逻辑,TypeScript 源码)
|
||||||
index.js 平台聚合入口
|
index.js 平台聚合入口
|
||||||
request.js SDK 请求模块(转接 src/common/request.js)
|
request.js SDK 请求模块(转接 src/common/request.js)
|
||||||
message.js SDK 消息模块(转接 src/common/message.js)
|
message.js SDK 消息模块(转接 src/common/message.js)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> TypeScript 源码(`src/apis/index.ts`、`musicSdk/**/*.ts`)经 `npm run build`(tsc)就地发射为 `.js` 产物后运行。
|
||||||
|
|
||||||
## 关于 lx-music-source-v5.js
|
## 关于 lx-music-source-v5.js
|
||||||
|
|
||||||
项目目录下的 `lx-music-source-v5.js` 是第三方混淆音源脚本。由于内容不可审计,且 Node.js `vm` 不是安全沙箱,服务器代码中**不再直接加载或执行该脚本**,也不依赖它获取播放地址。
|
项目目录下的 `lx-music-source-v5.js` 是第三方混淆音源脚本(`lx` 平台)。由于内容不可审计,它**不会在服务器进程内直接执行**,而是通过 `src/apis/lx-source-ivm.js` 使用 isolated-vm 硬隔离加载:脚本在独立 V8 沙箱中发起网络请求,服务器只做日志审计与 HTTP 白名单管控。其请求签名中的 `to`/`si` 两项为脚本内部哈希,无法脱离脚本复算,直连签名方案已放弃,详见 `docs/lx-source-ivm.md`。
|
||||||
|
|
||||||
如需复用其服务端能力,需要其提供去混淆源码或完整 API 文档。当前 `docs/lx-source-signing.md` 记录了已逆向出的确定性签名部分(`e`、`s2`、`s3`),但脚本内部用于 `to` / `si` 的混淆逻辑尚未安全还原,因此未集成到生产路径。
|
|
||||||
|
|
||||||
## 已知限制
|
## 已知限制
|
||||||
|
|
||||||
|
|||||||
+49
-70
@@ -1,26 +1,26 @@
|
|||||||
# MusicServer 聚合音乐 API
|
# MusicServer 聚合音乐 API
|
||||||
|
|
||||||
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。
|
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)/ **mg**(咪咕)。
|
||||||
支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)/ **mg**(咪咕)。
|
|
||||||
|
|
||||||
> 本文档为静态速查版。交互式文档以服务器实际输出为准:
|
> 本文档为静态速查版。交互式文档以服务器实际输出为准:
|
||||||
>
|
>
|
||||||
> - **Swagger UI**:`http://localhost:3300/docs`(可直接调试)
|
> - **Swagger UI**:`http://localhost:16688/docs`(可直接调试)
|
||||||
> - **ReDoc**:`http://localhost:3300/redoc`
|
> - **ReDoc**:`http://localhost:16688/redoc`
|
||||||
> - **OpenAPI 3.1**:`http://localhost:3300/openapi.json`(可生成各语言客户端)
|
> - **OpenAPI 3.1**:`http://localhost:16688/openapi.json`(可生成各语言客户端)
|
||||||
|
|
||||||
## 约定
|
## 约定
|
||||||
|
|
||||||
|
- 默认端口 **16688**(可用 `PORT` 环境变量修改)。
|
||||||
|
- 仅支持 `GET` 方法;服务器支持 CORS(`Access-Control-Allow-Origin: *`)。
|
||||||
- **成功**:`200` + `{"code": 0, "data": ...}`
|
- **成功**:`200` + `{"code": 0, "data": ...}`
|
||||||
- **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}`
|
- **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}`
|
||||||
- `422` 参数校验失败(缺失、非法音质、页码越界等)
|
- `422` 参数校验失败(缺失、非法音质、页码越界等,FastAPI 风格)
|
||||||
- `404` 接口不存在 / 平台不支持该能力
|
- `404` 接口不存在 / 平台不支持该能力
|
||||||
- `405` 方法不允许(仅支持 GET)
|
- `405` 方法不允许(响应带 `Allow` 头)
|
||||||
- `502` 上游平台请求失败
|
- `502` 上游平台请求失败
|
||||||
- `500` 服务器内部错误
|
- `500` 服务器内部错误
|
||||||
- 服务器支持 CORS(`*`),仅接受 `GET`。
|
- 每个资源接口同时提供**路径参数风格**(规范)与 **query 风格**(旧版兼容,文档中标记 deprecated)。
|
||||||
- 每个资源接口同时提供**路径参数风格**(规范)与**query 风格**(旧版兼容,已在文档中标记 deprecated)。
|
- 平台歌曲 id 形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`(mg 内部自动换取 contentId)。
|
||||||
- 平台歌曲 id 的形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`,mg 亦可用 `copyrightId`。
|
|
||||||
|
|
||||||
## 接口速查
|
## 接口速查
|
||||||
|
|
||||||
@@ -28,81 +28,60 @@
|
|||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| GET | `/health` | 健康检查(status / uptime / 平台数) |
|
||||||
|
| GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList) |
|
||||||
| GET | `/` | 302 重定向到 `/docs` |
|
| GET | `/` | 302 重定向到 `/docs` |
|
||||||
| GET | `/health` | 健康检查:`{status, uptime, platforms}` |
|
| GET | `/docs` | Swagger UI 文档页 |
|
||||||
| GET | `/sources` | 平台列表:id、名称、音质、能力矩阵 |
|
| GET | `/redoc` | ReDoc 阅读版文档 |
|
||||||
| GET | `/docs` | Swagger UI 交互文档 |
|
| GET | `/openapi.json` | OpenAPI 3.1 规范文档 |
|
||||||
| GET | `/redoc` | ReDoc 文档 |
|
|
||||||
| GET | `/openapi.json` | OpenAPI 3.1 规范 |
|
|
||||||
|
|
||||||
### 搜索
|
### 搜索
|
||||||
|
|
||||||
| 方法 | 路径 | 关键参数 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/search` | `name`(必填)、`singer`、`source`(省略=聚合)、`page=1`、`limit=20`(≤100) |
|
|
||||||
| GET | `/find` | `name`(必填)、`singer`、`albumName`、`interval`、`excludeSource` |
|
|
||||||
|
|
||||||
`/search` 指定 `source` 时只查该平台(失败返回 502);省略时聚合全部平台,失败的平台静默跳过。
|
|
||||||
`/find` 按歌手、歌名、专辑、时长综合打分,返回排序后的匹配列表。
|
|
||||||
|
|
||||||
### 歌曲资源(规范路由)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 关键参数 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/url/:source/:id` | `quality`(128k/320k/flac/flac24bit,默认 128k)、`albumId` |
|
|
||||||
| GET | `/lyric/:source/:id` | `name`、`interval`(部分平台搜索歌词时需要) |
|
|
||||||
| GET | `/pic/:source/:id` | `albumId`(kg 需要) |
|
|
||||||
|
|
||||||
旧版兼容:`/url?source=&id=&quality=`、`/lyric?source=&id=`、`/pic?source=&id=`。
|
|
||||||
|
|
||||||
### 榜单(规范路由)
|
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/boards/:source` | 榜单列表 `{list:[{id,name,bangid}]}` |
|
| GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台(排除 xm),失败的平台跳过 |
|
||||||
| GET | `/boards/:source/:boardId` | 榜单详情,`page=1`,返回歌曲列表 |
|
| GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 |
|
||||||
|
|
||||||
旧版兼容:`/boards?source=`(列表)、`/board?source=&id=&page=`(详情)。
|
### 歌曲资源
|
||||||
|
|
||||||
### 歌单 & 热搜(规范路由)
|
| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/url/:source/:id` | `/url?source=&id=` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) |
|
||||||
|
| GET | `/lyric/:source/:id` | `/lyric?source=&id=` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) |
|
||||||
|
| GET | `/pic/:source/:id` | `/pic?source=&id=` | 封面;可带 `albumId`(kg 需要) |
|
||||||
|
|
||||||
| 方法 | 路径 | 关键参数 |
|
### 榜单
|
||||||
| --- | --- | --- |
|
|
||||||
| GET | `/song-list/:source` | `page=1`、`sortId=3`、`tagId=0`(平台相关) |
|
|
||||||
| GET | `/hot-search/:source` | 无 |
|
|
||||||
|
|
||||||
旧版兼容:`/songList?source=&page=&sortId=&tagId=`、`/hotSearch?source=`。
|
| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/boards/:source` | `/boards?source=` | 榜单列表 |
|
||||||
|
| GET | `/boards/:source/:boardId` | `/board?source=&id=` | 榜单详情;可带 `page`。`boardId` 兼容榜单列表的带前缀 `id`(`kw__93`)与裸 `bangid`(`93`) |
|
||||||
|
|
||||||
## 响应数据示例
|
### 歌单 & 热搜
|
||||||
|
|
||||||
|
| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| GET | `/hot-search/:source` | `/hotSearch?source=` | 热搜词 |
|
||||||
|
| GET | `/song-list/:source` | `/songList?source=` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 |
|
||||||
|
|
||||||
|
## 响应示例
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// GET /search?source=kw&name=稻香&limit=2
|
// GET /url/kw/228908?quality=320k
|
||||||
{ "code": 0, "data": { "source": "kw", "list": [ /* SongInfo */ ], "page": 1, "limit": 2 } }
|
{ "code": 0, "data": { "type": "320k", "url": "https://..." } }
|
||||||
|
|
||||||
// SongInfo(节选)
|
// GET /lyric/kw/228908
|
||||||
{
|
{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "lxlyric": "" } }
|
||||||
"songmid": "28423286", "hash": "...", "copyrightId": "...", "albumId": "...",
|
|
||||||
"name": "稻香", "singer": "周杰伦", "source": "kw", "interval": "03:43",
|
|
||||||
"albumName": "魔杰座", "img": "https://...",
|
|
||||||
"types": [{ "type": "320k", "size": "9.8M" }],
|
|
||||||
"_types": { "320k": { "size": "9.8M" } },
|
|
||||||
"typeUrl": {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /url/kw/28423286?quality=320k
|
// 参数缺失
|
||||||
{ "code": 0, "data": { "type": "320k", "url": "http://..." } }
|
// 422
|
||||||
|
{ "code": 422, "msg": "querystring must have required property 'name'", "detail": [...] }
|
||||||
|
|
||||||
// GET /lyric/kw/28423286
|
// 平台不支持该能力
|
||||||
{ "code": 0, "data": { "lyric": "[ti:稻香]...", "tlyric": "", "lxlyric": "..." } }
|
// 404
|
||||||
|
{ "code": 404, "msg": "不支持的平台: xx" }
|
||||||
|
|
||||||
// 422 示例
|
// 方法不允许
|
||||||
{ "code": 422, "msg": "不支持的音质: 999k", "detail": { "enum": ["128k","320k","flac","flac24bit"] } }
|
// 405 + Allow: GET
|
||||||
```
|
{ "code": 405, "msg": "方法 POST 不允许,请使用 GET" }
|
||||||
|
|
||||||
## 客户端生成
|
|
||||||
|
|
||||||
`/openapi.json` 兼容 OpenAPI 3.1,可直接用工具生成客户端:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx openapi-typescript http://localhost:3300/openapi.json -o api-types.ts
|
|
||||||
```
|
```
|
||||||
@@ -14,7 +14,11 @@
|
|||||||
"#renderer/*": "./src/renderer/*.js"
|
"#renderer/*": "./src/renderer/*.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fastify/cors": "^11.3.0",
|
||||||
|
"@fastify/swagger": "^9.8.1",
|
||||||
|
"@fastify/swagger-ui": "^6.1.1",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
|
"fastify": "^5.12.1",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
"isolated-vm": "^7.0.1",
|
"isolated-vm": "^7.0.1",
|
||||||
"pako": "^2.1.0"
|
"pako": "^2.1.0"
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 参数校验失败(FastAPI 风格使用 422) */
|
|
||||||
export const invalidParam = (msg, detail) => new ApiError(422, msg, detail)
|
|
||||||
|
|
||||||
/** 资源不存在(未知路由 / 不支持的平台) */
|
/** 资源不存在(未知路由 / 不支持的平台) */
|
||||||
export const notFound = (msg, detail) => new ApiError(404, msg, detail)
|
export const notFound = (msg, detail) => new ApiError(404, msg, detail)
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
// 极简 HTTP 路由器 —— 路径参数(:name)、405 检测、携带路由元数据(供 OpenAPI 文档生成)
|
|
||||||
|
|
||||||
export class Router {
|
|
||||||
#routes = []
|
|
||||||
|
|
||||||
get(pattern, meta, handler) {
|
|
||||||
this.#add('GET', pattern, meta, handler)
|
|
||||||
return this
|
|
||||||
}
|
|
||||||
|
|
||||||
#add(method, pattern, meta, handler) {
|
|
||||||
const segments = pattern.replace(/^\/+|\/+$/g, '').split('/')
|
|
||||||
this.#routes.push({ method, pattern, segments, meta: meta || {}, handler })
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 匹配路由
|
|
||||||
* @returns {null | { handler: Function, meta: object, params: Record<string,string> } | { allowed: string[] }}
|
|
||||||
* - null:无匹配路由(404)
|
|
||||||
* - { allowed }:路径存在但方法不符(405)
|
|
||||||
*/
|
|
||||||
match(pathname, method) {
|
|
||||||
const parts = pathname.replace(/^\/+|\/+$/g, '').split('/')
|
|
||||||
const allowed = new Set()
|
|
||||||
for (const route of this.#routes) {
|
|
||||||
const params = matchSegments(route.segments, parts)
|
|
||||||
if (!params) continue
|
|
||||||
if (route.method !== method) {
|
|
||||||
allowed.add(route.method)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return { handler: route.handler, meta: route.meta, params }
|
|
||||||
}
|
|
||||||
if (allowed.size) return { allowed: [...allowed] }
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 路由表(OpenAPI 文档生成用) */
|
|
||||||
get routes() {
|
|
||||||
return this.#routes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const matchSegments = (defined, actual) => {
|
|
||||||
if (defined.length !== actual.length) return null
|
|
||||||
const params = {}
|
|
||||||
for (let i = 0; i < defined.length; i++) {
|
|
||||||
const seg = defined[i]
|
|
||||||
if (seg.startsWith(':')) params[seg.slice(1)] = decodeURIComponent(actual[i])
|
|
||||||
else if (seg !== actual[i]) return null
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
@@ -1,78 +1,24 @@
|
|||||||
// OpenAPI 3.1 文档生成 —— 从路由元数据构建 /openapi.json,供 Swagger UI(/docs)与 ReDoc(/redoc)使用
|
// OpenAPI 领域模型 —— 注册为 Fastify 共享 schema(fastify.addSchema)
|
||||||
|
// 路由的 response schema 通过 `名称#` 形式的 $ref 引用;
|
||||||
|
// @fastify/swagger 生成文档时会自动映射到 components/schemas/名称
|
||||||
|
// 注:additionalProperties: true 保证运行时序列化不剥离未声明字段
|
||||||
|
|
||||||
const json = (schema) => ({ 'application/json': { schema } })
|
const ref = (name) => ({ $ref: `${name}#` })
|
||||||
const ref = (name) => ({ $ref: `#/components/schemas/${name}` })
|
|
||||||
|
|
||||||
/** 200 响应体:统一信封 { code: 0, data } */
|
|
||||||
const okResponse = (dataSchema, description) => ({
|
|
||||||
description: description || '成功',
|
|
||||||
content: {
|
|
||||||
'application/json': {
|
|
||||||
schema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
code: { type: 'integer', description: '0 表示成功', example: 0 },
|
|
||||||
data: dataSchema ? ref(dataSchema) : { description: '响应数据,结构由接口决定' },
|
|
||||||
},
|
|
||||||
required: ['code', 'data'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const errorResponse = (description) => ({
|
|
||||||
description,
|
|
||||||
content: json(ref('ErrorEnvelope')),
|
|
||||||
})
|
|
||||||
|
|
||||||
const buildParameters = (meta) => {
|
|
||||||
const parameters = []
|
|
||||||
for (const [name, def] of Object.entries(meta.pathParams || {})) {
|
|
||||||
parameters.push({
|
|
||||||
name,
|
|
||||||
in: 'path',
|
|
||||||
required: true,
|
|
||||||
description: def.description,
|
|
||||||
schema: paramSchema(def),
|
|
||||||
example: def.example,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
for (const [name, def] of Object.entries(meta.queryParams || {})) {
|
|
||||||
parameters.push({
|
|
||||||
name,
|
|
||||||
in: 'query',
|
|
||||||
required: Boolean(def.required),
|
|
||||||
description: def.description,
|
|
||||||
schema: paramSchema(def),
|
|
||||||
example: def.example,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return parameters
|
|
||||||
}
|
|
||||||
|
|
||||||
const paramSchema = (def) => {
|
|
||||||
const schema = { type: def.enum ? 'string' : (def.type || 'string') }
|
|
||||||
if (def.enum) schema.enum = def.enum
|
|
||||||
if (def.default !== undefined) schema.default = def.default
|
|
||||||
if (def.minimum !== undefined) schema.minimum = def.minimum
|
|
||||||
if (def.maximum !== undefined) schema.maximum = def.maximum
|
|
||||||
return schema
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 领域模型(components/schemas),与 musicSdk/types.ts 的运行时数据形状对应 */
|
|
||||||
export const COMPONENT_SCHEMAS = {
|
export const COMPONENT_SCHEMAS = {
|
||||||
ErrorEnvelope: {
|
ErrorEnvelope: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
code: { type: 'integer', description: '与 HTTP 状态码一致', example: 422 },
|
code: { type: 'integer', description: '与 HTTP 状态码一致', example: 422 },
|
||||||
msg: { type: 'string', description: '错误描述' },
|
msg: { type: 'string', description: '错误描述' },
|
||||||
detail: { description: '可选的结构化错误详情' },
|
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
required: ['code', 'msg'],
|
required: ['code', 'msg'],
|
||||||
},
|
},
|
||||||
QualityInfo: {
|
QualityInfo: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { size: { type: 'string', example: '9.8M' } },
|
properties: { size: { type: 'string', example: '9.8M' } },
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SongType: {
|
SongType: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -80,6 +26,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
type: { type: 'string', example: '320k', enum: ['128k', '320k', 'flac', 'flac24bit'] },
|
type: { type: 'string', example: '320k', enum: ['128k', '320k', 'flac', 'flac24bit'] },
|
||||||
size: { type: 'string', example: '9.8M' },
|
size: { type: 'string', example: '9.8M' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SongInfo: {
|
SongInfo: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -98,6 +45,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
_types: { type: 'object', additionalProperties: ref('QualityInfo') },
|
_types: { type: 'object', additionalProperties: ref('QualityInfo') },
|
||||||
typeUrl: { type: 'object' },
|
typeUrl: { type: 'object' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SearchResult: {
|
SearchResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -107,12 +55,14 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
page: { type: 'integer' },
|
page: { type: 'integer' },
|
||||||
limit: { type: 'integer' },
|
limit: { type: 'integer' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
FindResult: {
|
FindResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
list: { type: 'array', items: ref('SongInfo'), description: '按匹配度排序的歌曲列表' },
|
list: { type: 'array', items: ref('SongInfo'), description: '按匹配度排序的歌曲列表' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
MusicUrlResult: {
|
MusicUrlResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -120,6 +70,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
type: { type: 'string', example: '320k' },
|
type: { type: 'string', example: '320k' },
|
||||||
url: { type: 'string', description: '播放地址' },
|
url: { type: 'string', description: '播放地址' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
LyricInfo: {
|
LyricInfo: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -129,11 +80,13 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
rlyric: { type: 'string', description: '罗马音歌词(部分平台)' },
|
rlyric: { type: 'string', description: '罗马音歌词(部分平台)' },
|
||||||
lxlyric: { type: 'string', description: '逐字歌词(部分平台)' },
|
lxlyric: { type: 'string', description: '逐字歌词(部分平台)' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
required: ['lyric', 'tlyric'],
|
required: ['lyric', 'tlyric'],
|
||||||
},
|
},
|
||||||
PicResult: {
|
PicResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { url: { type: 'string', description: '封面地址' } },
|
properties: { url: { type: 'string', description: '封面地址' } },
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
BoardItem: {
|
BoardItem: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -142,10 +95,12 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
name: { type: 'string' },
|
name: { type: 'string' },
|
||||||
bangid: { type: 'string', description: '平台原始榜单 id' },
|
bangid: { type: 'string', description: '平台原始榜单 id' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
BoardListResult: {
|
BoardListResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: { list: { type: 'array', items: ref('BoardItem') } },
|
properties: { list: { type: 'array', items: ref('BoardItem') } },
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SongItem: {
|
SongItem: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -161,6 +116,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
img: { type: 'string' },
|
img: { type: 'string' },
|
||||||
types: { type: 'array', items: ref('SongType') },
|
types: { type: 'array', items: ref('SongType') },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
BoardDetailResult: {
|
BoardDetailResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -171,6 +127,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
limit: { type: 'integer' },
|
limit: { type: 'integer' },
|
||||||
source: { type: 'string' },
|
source: { type: 'string' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
HotSearchResult: {
|
HotSearchResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -178,6 +135,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
source: { type: 'string' },
|
source: { type: 'string' },
|
||||||
list: { type: 'array', items: { type: 'string' } },
|
list: { type: 'array', items: { type: 'string' } },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SongListSummary: {
|
SongListSummary: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -192,6 +150,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
desc: { type: 'string' },
|
desc: { type: 'string' },
|
||||||
source: { type: 'string' },
|
source: { type: 'string' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SongListResult: {
|
SongListResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -202,6 +161,7 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
limit: { type: 'integer' },
|
limit: { type: 'integer' },
|
||||||
source: { type: 'string' },
|
source: { type: 'string' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SourceInfo: {
|
SourceInfo: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -220,14 +180,17 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
hotSearch: { type: 'boolean' },
|
hotSearch: { type: 'boolean' },
|
||||||
songList: { type: 'boolean' },
|
songList: { type: 'boolean' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
SourcesResult: {
|
SourcesResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
platforms: { type: 'array', items: ref('SourceInfo') },
|
platforms: { type: 'array', items: ref('SourceInfo') },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
HealthResult: {
|
HealthResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -236,46 +199,6 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
uptime: { type: 'integer', description: '运行秒数' },
|
uptime: { type: 'integer', description: '运行秒数' },
|
||||||
platforms: { type: 'integer', description: '已接入平台数' },
|
platforms: { type: 'integer', description: '已接入平台数' },
|
||||||
},
|
},
|
||||||
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从路由元数据构建 OpenAPI 3.1 文档
|
|
||||||
* @param {import('../http/router.js').Router} router
|
|
||||||
*/
|
|
||||||
export const buildOpenApi = (router, { title, description, version }) => {
|
|
||||||
const paths = {}
|
|
||||||
for (const route of router.routes) {
|
|
||||||
if (route.meta.hide) continue
|
|
||||||
const item = paths[route.pattern] || (paths[route.pattern] = {})
|
|
||||||
const meta = route.meta
|
|
||||||
item[route.method.toLowerCase()] = {
|
|
||||||
summary: meta.summary || route.pattern,
|
|
||||||
...(meta.description ? { description: meta.description } : {}),
|
|
||||||
tags: meta.tags || ['其他'],
|
|
||||||
...(meta.deprecated ? { deprecated: true } : {}),
|
|
||||||
parameters: buildParameters(meta),
|
|
||||||
responses: meta.rawResponses || {
|
|
||||||
'200': okResponse(meta.dataSchema, meta.responseDescription),
|
|
||||||
'422': errorResponse('参数校验失败'),
|
|
||||||
...(meta.possible404 ? { '404': errorResponse('不支持的平台') } : {}),
|
|
||||||
...(meta.possible502 ? { '502': errorResponse('上游平台请求失败') } : {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
openapi: '3.1.0',
|
|
||||||
info: { title, description, version },
|
|
||||||
servers: [{ url: '/', description: '当前服务器' }],
|
|
||||||
tags: [
|
|
||||||
{ name: '搜索', description: '歌曲搜索与跨平台匹配' },
|
|
||||||
{ name: '歌曲资源', description: '播放地址、歌词、封面' },
|
|
||||||
{ name: '榜单', description: '排行榜' },
|
|
||||||
{ name: '歌单', description: '歌单与热搜' },
|
|
||||||
{ name: '系统', description: '健康检查、平台信息、文档' },
|
|
||||||
],
|
|
||||||
paths,
|
|
||||||
components: { schemas: COMPONENT_SCHEMAS },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+6
-33
@@ -1,44 +1,17 @@
|
|||||||
// 文档页面 —— Swagger UI(/docs)与 ReDoc(/redoc),静态 HTML + CDN 资源
|
// ReDoc 文档页 —— Swagger UI 由 @fastify/swagger-ui 提供,此处仅保留 ReDoc 版本
|
||||||
|
|
||||||
const SWAGGER_CDN = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui.css'
|
|
||||||
const SWAGGER_BUNDLE_CDN = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js'
|
|
||||||
const REDOC_CDN = 'https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js'
|
const REDOC_CDN = 'https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js'
|
||||||
|
|
||||||
const htmlShell = (title, body) => `<!DOCTYPE html>
|
export const redocHtml = ({ title, specUrl = '/openapi.json' }) => `<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>${title}</title>
|
<title>${title} (ReDoc)</title>
|
||||||
${body.head}
|
<style>body { margin: 0; padding: 0; }</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
${body.content}
|
<redoc spec-url="${specUrl}" hide-download-button="true"></redoc>
|
||||||
|
<script src="${REDOC_CDN}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|
||||||
export const swaggerHtml = ({ title, specUrl = '/openapi.json' }) => htmlShell(title, {
|
|
||||||
head: `
|
|
||||||
<link rel="stylesheet" href="${SWAGGER_CDN}">
|
|
||||||
<style>body { margin: 0; }</style>`,
|
|
||||||
content: `
|
|
||||||
<div id="swagger-ui"></div>
|
|
||||||
<script src="${SWAGGER_BUNDLE_CDN}" crossorigin></script>
|
|
||||||
<script>
|
|
||||||
window.onload = () => window.ui = SwaggerUIBundle({
|
|
||||||
url: '${specUrl}',
|
|
||||||
dom_id: '#swagger-ui',
|
|
||||||
docExpansion: 'none',
|
|
||||||
persistAuthorization: true,
|
|
||||||
tryItOutEnabled: true,
|
|
||||||
})
|
|
||||||
</script>`,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const redocHtml = ({ title, specUrl = '/openapi.json' }) => htmlShell(`${title} (ReDoc)`, {
|
|
||||||
head: `
|
|
||||||
<style>body { margin: 0; padding: 0; }</style>`,
|
|
||||||
content: `
|
|
||||||
<redoc spec-url="${specUrl}" hide-download-button="true"></redoc>
|
|
||||||
<script src="${REDOC_CDN}"></script>`,
|
|
||||||
})
|
|
||||||
+245
-263
@@ -1,9 +1,9 @@
|
|||||||
// 路由表 —— 接口定义 + OpenAPI 元数据 + 处理函数
|
// 业务路由 —— Fastify 插件形式
|
||||||
// 处理函数返回普通对象 → 200 { code: 0, data }
|
// schema(querystring/params)负责参数校验并生成文档;schema.response 负责文档中的响应结构
|
||||||
// 抛出 ApiError → 对应 HTTP 状态码;抛出其他错误 → 500
|
// 处理函数返回普通对象 → 由服务器的 preSerialization 钩子包成 { code: 0, data }
|
||||||
// 返回 { __raw: true, status, headers, body } → 原样输出(文档页/重定向用)
|
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
|
||||||
|
|
||||||
import { invalidParam, notFound, upstream } from './http/api-error.js'
|
import { notFound, upstream } from './http/api-error.js'
|
||||||
|
|
||||||
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
|
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
|
||||||
|
|
||||||
@@ -14,16 +14,37 @@ const unwrap = (result) => (
|
|||||||
: result
|
: result
|
||||||
)
|
)
|
||||||
|
|
||||||
const required = (query, names) => {
|
const envelope = (dataRef) => ({
|
||||||
const missing = names.filter(n => !query[n])
|
type: 'object',
|
||||||
if (missing.length) throw invalidParam(`缺少参数: ${missing.join(', ')}`, { missing })
|
properties: {
|
||||||
}
|
code: { type: 'integer', description: '0 表示成功', example: 0 },
|
||||||
|
data: dataRef ? { $ref: `${dataRef}#` } : {},
|
||||||
|
},
|
||||||
|
required: ['code', 'data'],
|
||||||
|
})
|
||||||
|
|
||||||
const toInt = (value, name, { min = 1, max = Infinity, def }) => {
|
/** 组装路由的 schema.response:200 信封 + 通用错误码文档 */
|
||||||
const n = parseInt(value ?? def, 10)
|
const responses = (dataRef, { notFoundDoc = true, upstreamDoc = true } = {}) => ({
|
||||||
if (Number.isNaN(n) || n < min || n > max) throw invalidParam(`参数 ${name} 需为 ${min}~${max} 的整数`)
|
'200': envelope(dataRef),
|
||||||
return n
|
'422': { $ref: 'ErrorEnvelope#' },
|
||||||
}
|
...(notFoundDoc ? { '404': { $ref: 'ErrorEnvelope#' } } : {}),
|
||||||
|
...(upstreamDoc ? { '502': { $ref: 'ErrorEnvelope#' } } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 常用 JSON Schema 片段 ----
|
||||||
|
|
||||||
|
const pSource = (capability) => ({
|
||||||
|
type: 'string',
|
||||||
|
description: `平台 id(见 /sources)${capability ? `,需支持 ${capability} 能力` : ''}`,
|
||||||
|
example: 'kw',
|
||||||
|
})
|
||||||
|
const pPage = { type: 'integer', default: 1, minimum: 1, description: '页码' }
|
||||||
|
const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '每页条数' }
|
||||||
|
const pQuality = { type: 'string', enum: QUALITIES, default: '128k', description: '音质' }
|
||||||
|
const pId = { type: 'string', description: '平台歌曲 id', example: '28423286' }
|
||||||
|
|
||||||
|
const qs = (properties, required = []) => ({ type: 'object', properties, required })
|
||||||
|
const params = (properties, required) => qs(properties, required ?? Object.keys(properties))
|
||||||
|
|
||||||
const getSource = (musicSdk, name, capability) => {
|
const getSource = (musicSdk, name, capability) => {
|
||||||
const m = musicSdk[name]
|
const m = musicSdk[name]
|
||||||
@@ -31,55 +52,40 @@ const getSource = (musicSdk, name, capability) => {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
// 常用查询参数元数据(多接口复用)
|
|
||||||
const pSource = (capability, description) => ({
|
|
||||||
description: description || `平台 id(见 /sources)${capability ? `,需支持 ${capability} 能力` : ''}`,
|
|
||||||
example: 'kw',
|
|
||||||
})
|
|
||||||
const pPage = { type: 'integer', default: 1, minimum: 1, description: '页码' }
|
|
||||||
const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '每页条数' }
|
|
||||||
const pQuality = {
|
|
||||||
enum: QUALITIES,
|
|
||||||
default: '128k',
|
|
||||||
description: '音质',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注册全部业务路由
|
* 注册全部业务路由
|
||||||
* @param {import('./http/router.js').Router} router
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
* @param {object} musicSdk 聚合 SDK
|
|
||||||
*/
|
*/
|
||||||
export const registerRoutes = (router, musicSdk) => {
|
export const musicRoutes = async(fastify) => {
|
||||||
|
const musicSdk = fastify.musicSdk
|
||||||
|
|
||||||
// ---------- 搜索 ----------
|
// ---------- 搜索 ----------
|
||||||
|
|
||||||
router.get('/search', {
|
fastify.get('/search', {
|
||||||
|
schema: {
|
||||||
summary: '搜索歌曲',
|
summary: '搜索歌曲',
|
||||||
description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。',
|
description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。',
|
||||||
tags: ['搜索'],
|
tags: ['搜索'],
|
||||||
queryParams: {
|
querystring: qs({
|
||||||
name: { required: true, description: '歌名(可附带歌手,空格分隔)', example: '稻香 周杰伦' },
|
name: { type: 'string', description: '歌名(可附带歌手,空格分隔)', example: '稻香 周杰伦' },
|
||||||
singer: { description: '歌手名', example: '周杰伦' },
|
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
|
||||||
source: pSource('search'),
|
source: pSource('search'),
|
||||||
page: pPage,
|
page: pPage,
|
||||||
limit: pLimit,
|
limit: pLimit,
|
||||||
|
}, ['name']),
|
||||||
|
response: responses('SearchResult', { notFoundDoc: false }),
|
||||||
},
|
},
|
||||||
dataSchema: 'SearchResult',
|
}, async(request) => {
|
||||||
possible502: true,
|
const { name, singer, source, page, limit } = request.query
|
||||||
}, async({ query }) => {
|
|
||||||
required(query, ['name'])
|
|
||||||
const name = query.name
|
|
||||||
const singer = query.singer
|
|
||||||
const page = toInt(query.page, 'page', { def: 1 })
|
|
||||||
const limit = toInt(query.limit, 'limit', { def: 20, max: 100 })
|
|
||||||
|
|
||||||
const doSearch = (id) => musicSdk[id].musicSearch
|
const doSearch = (id) => musicSdk[id].musicSearch
|
||||||
.search(`${name} ${singer || ''}`.trim(), page, limit)
|
.search(`${name} ${singer || ''}`.trim(), page, limit)
|
||||||
.then(r => r.list)
|
.then(r => r.list)
|
||||||
|
|
||||||
if (query.source) {
|
if (source) {
|
||||||
getSource(musicSdk, query.source, 'musicSearch')
|
getSource(musicSdk, source, 'musicSearch')
|
||||||
try {
|
try {
|
||||||
return { source: query.source, list: await doSearch(query.source), page, limit }
|
return { source, list: await doSearch(source), page, limit }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw upstream(err.message)
|
throw upstream(err.message)
|
||||||
}
|
}
|
||||||
@@ -93,40 +99,38 @@ export const registerRoutes = (router, musicSdk) => {
|
|||||||
return { source: 'all', list: lists.flat(), page, limit }
|
return { source: 'all', list: lists.flat(), page, limit }
|
||||||
})
|
})
|
||||||
|
|
||||||
router.get('/find', {
|
fastify.get('/find', {
|
||||||
|
schema: {
|
||||||
summary: '跨平台精确匹配歌曲',
|
summary: '跨平台精确匹配歌曲',
|
||||||
description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。',
|
description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。',
|
||||||
tags: ['搜索'],
|
tags: ['搜索'],
|
||||||
queryParams: {
|
querystring: qs({
|
||||||
name: { required: true, description: '歌名', example: '稻香' },
|
name: { type: 'string', description: '歌名', example: '稻香' },
|
||||||
singer: { description: '歌手名', example: '周杰伦' },
|
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
|
||||||
albumName: { description: '专辑名', example: '魔杰座' },
|
albumName: { type: 'string', description: '专辑名', example: '魔杰座' },
|
||||||
interval: { description: '时长(mm:ss),用于时长校验', example: '03:43' },
|
interval: { type: 'string', description: '时长(mm:ss),用于时长校验', example: '03:43' },
|
||||||
excludeSource: { description: '聚合结果中跳过该平台', example: 'kw' },
|
excludeSource: { type: 'string', description: '聚合结果中跳过该平台', example: 'kw' },
|
||||||
|
}, ['name']),
|
||||||
|
response: responses('FindResult', { notFoundDoc: false }),
|
||||||
},
|
},
|
||||||
dataSchema: 'FindResult',
|
}, async(request) => {
|
||||||
possible502: true,
|
const { name, singer, albumName, interval, excludeSource } = request.query
|
||||||
}, async({ query }) => {
|
|
||||||
required(query, ['name'])
|
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
list: await musicSdk.findMusic({
|
list: await musicSdk.findMusic({ name, singer, albumName, interval, source: excludeSource }),
|
||||||
name: query.name,
|
|
||||||
singer: query.singer,
|
|
||||||
albumName: query.albumName,
|
|
||||||
interval: query.interval,
|
|
||||||
source: query.excludeSource,
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw upstream(err.message)
|
throw upstream(err.message)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- 歌曲资源(路径参数风格,含旧 query 风格别名) ----------
|
// ---------- 歌曲资源(路径参数风格 + 旧 query 风格别名) ----------
|
||||||
|
|
||||||
const urlHandler = ({ source, id, quality, albumId }) => {
|
const urlHandler = async(request) => {
|
||||||
if (!QUALITIES.includes(quality)) throw invalidParam(`不支持的音质: ${quality}`, { enum: QUALITIES })
|
const q = request.params.id
|
||||||
|
? { source: request.params.source, id: request.params.id, ...request.query }
|
||||||
|
: request.query
|
||||||
|
const { source, id, quality, albumId } = q
|
||||||
const m = getSource(musicSdk, source, 'getMusicUrl')
|
const m = getSource(musicSdk, source, 'getMusicUrl')
|
||||||
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
|
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
|
||||||
let result
|
let result
|
||||||
@@ -139,47 +143,44 @@ export const registerRoutes = (router, musicSdk) => {
|
|||||||
if (!res?.url) throw upstream('获取播放地址失败')
|
if (!res?.url) throw upstream('获取播放地址失败')
|
||||||
return { type: quality, url: res.url }
|
return { type: quality, url: res.url }
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
throw err instanceof Error && err.status ? err : upstream(err.message)
|
throw err.status ? err : upstream(err.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/url/:source/:id', {
|
fastify.get('/url/:source/:id', {
|
||||||
|
schema: {
|
||||||
summary: '获取播放地址',
|
summary: '获取播放地址',
|
||||||
description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。',
|
description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。',
|
||||||
tags: ['歌曲资源'],
|
tags: ['歌曲资源'],
|
||||||
pathParams: {
|
params: params({
|
||||||
source: pSource('url'),
|
source: pSource('url'),
|
||||||
id: { description: '平台歌曲 id', example: '28423286' },
|
id: pId,
|
||||||
|
}),
|
||||||
|
querystring: qs({ quality: pQuality, albumId: { type: 'string', description: '专辑 id' } }),
|
||||||
|
response: responses('MusicUrlResult'),
|
||||||
},
|
},
|
||||||
queryParams: {
|
}, urlHandler)
|
||||||
quality: pQuality,
|
|
||||||
albumId: { description: '专辑 id(部分平台获取封面时需要)' },
|
|
||||||
},
|
|
||||||
dataSchema: 'MusicUrlResult',
|
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ params, query }) => urlHandler({
|
|
||||||
source: params.source, id: params.id, quality: query.quality || '128k', albumId: query.albumId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/url', {
|
fastify.get('/url', {
|
||||||
|
schema: {
|
||||||
summary: '获取播放地址(旧版 query 风格)',
|
summary: '获取播放地址(旧版 query 风格)',
|
||||||
tags: ['歌曲资源'],
|
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
queryParams: {
|
tags: ['歌曲资源'],
|
||||||
source: { ...pSource('url'), required: true },
|
querystring: qs({
|
||||||
id: { required: true, description: '平台歌曲 id' },
|
source: pSource('url'),
|
||||||
|
id: pId,
|
||||||
quality: pQuality,
|
quality: pQuality,
|
||||||
albumId: { description: '专辑 id' },
|
albumId: { type: 'string', description: '专辑 id' },
|
||||||
|
}, ['source', 'id']),
|
||||||
|
response: responses('MusicUrlResult'),
|
||||||
},
|
},
|
||||||
dataSchema: 'MusicUrlResult',
|
}, urlHandler)
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ query }) => urlHandler({
|
|
||||||
source: query.source, id: query.id, quality: query.quality || '128k', albumId: query.albumId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const lyricHandler = ({ source, id, name, interval }) => {
|
const lyricHandler = async(request) => {
|
||||||
|
const q = request.params.id
|
||||||
|
? { source: request.params.source, id: request.params.id, ...request.query }
|
||||||
|
: request.query
|
||||||
|
const { source, id, name, interval } = q
|
||||||
const m = getSource(musicSdk, source, 'getLyric')
|
const m = getSource(musicSdk, source, 'getLyric')
|
||||||
let result
|
let result
|
||||||
try {
|
try {
|
||||||
@@ -188,46 +189,43 @@ export const registerRoutes = (router, musicSdk) => {
|
|||||||
throw upstream(err.message)
|
throw upstream(err.message)
|
||||||
}
|
}
|
||||||
return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => {
|
return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => {
|
||||||
throw err instanceof Error && err.status ? err : upstream(err.message)
|
throw err.status ? err : upstream(err.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/lyric/:source/:id', {
|
fastify.get('/lyric/:source/:id', {
|
||||||
|
schema: {
|
||||||
summary: '获取歌词',
|
summary: '获取歌词',
|
||||||
tags: ['歌曲资源'],
|
tags: ['歌曲资源'],
|
||||||
pathParams: {
|
params: params({ source: pSource('lyric'), id: pId }),
|
||||||
source: pSource('lyric'),
|
querystring: qs({
|
||||||
id: { description: '平台歌曲 id', example: '28423286' },
|
name: { type: 'string', description: '歌名(部分平台搜索歌词时需要)' },
|
||||||
|
interval: { type: 'string', description: '时长(mm:ss),部分平台搜索歌词时需要' },
|
||||||
|
}),
|
||||||
|
response: responses('LyricInfo'),
|
||||||
},
|
},
|
||||||
queryParams: {
|
}, lyricHandler)
|
||||||
name: { description: '歌名(部分平台搜索歌词时需要)' },
|
|
||||||
interval: { description: '时长(mm:ss),部分平台搜索歌词时需要' },
|
|
||||||
},
|
|
||||||
dataSchema: 'LyricInfo',
|
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ params, query }) => lyricHandler({
|
|
||||||
source: params.source, id: params.id, name: query.name, interval: query.interval,
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/lyric', {
|
fastify.get('/lyric', {
|
||||||
|
schema: {
|
||||||
summary: '获取歌词(旧版 query 风格)',
|
summary: '获取歌词(旧版 query 风格)',
|
||||||
tags: ['歌曲资源'],
|
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
queryParams: {
|
tags: ['歌曲资源'],
|
||||||
source: { ...pSource('lyric'), required: true },
|
querystring: qs({
|
||||||
id: { required: true, description: '平台歌曲 id' },
|
source: pSource('lyric'),
|
||||||
name: { description: '歌名' },
|
id: pId,
|
||||||
interval: { description: '时长(mm:ss)' },
|
name: { type: 'string', description: '歌名' },
|
||||||
|
interval: { type: 'string', description: '时长(mm:ss)' },
|
||||||
|
}, ['source', 'id']),
|
||||||
|
response: responses('LyricInfo'),
|
||||||
},
|
},
|
||||||
dataSchema: 'LyricInfo',
|
}, lyricHandler)
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ query }) => lyricHandler({
|
|
||||||
source: query.source, id: query.id, name: query.name, interval: query.interval,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const picHandler = ({ source, id, albumId }) => {
|
const picHandler = async(request) => {
|
||||||
|
const q = request.params.id
|
||||||
|
? { source: request.params.source, id: request.params.id, ...request.query }
|
||||||
|
: request.query
|
||||||
|
const { source, id, albumId } = q
|
||||||
const m = getSource(musicSdk, source, 'getPic')
|
const m = getSource(musicSdk, source, 'getPic')
|
||||||
let result
|
let result
|
||||||
try {
|
try {
|
||||||
@@ -236,205 +234,189 @@ export const registerRoutes = (router, musicSdk) => {
|
|||||||
throw upstream(err.message)
|
throw upstream(err.message)
|
||||||
}
|
}
|
||||||
return result.then(url => ({ url })).catch(err => {
|
return result.then(url => ({ url })).catch(err => {
|
||||||
throw err instanceof Error && err.status ? err : upstream(err.message)
|
throw err.status ? err : upstream(err.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/pic/:source/:id', {
|
fastify.get('/pic/:source/:id', {
|
||||||
|
schema: {
|
||||||
summary: '获取封面',
|
summary: '获取封面',
|
||||||
tags: ['歌曲资源'],
|
tags: ['歌曲资源'],
|
||||||
pathParams: {
|
params: params({ source: pSource('pic'), id: pId }),
|
||||||
source: pSource('pic'),
|
querystring: qs({ albumId: { type: 'string', description: '专辑 id(kg 平台需要)' } }),
|
||||||
id: { description: '平台歌曲 id', example: '28423286' },
|
response: responses('PicResult'),
|
||||||
},
|
},
|
||||||
queryParams: {
|
}, picHandler)
|
||||||
albumId: { description: '专辑 id(kg 平台需要)' },
|
|
||||||
},
|
|
||||||
dataSchema: 'PicResult',
|
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ params, query }) => picHandler({
|
|
||||||
source: params.source, id: params.id, albumId: query.albumId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/pic', {
|
fastify.get('/pic', {
|
||||||
|
schema: {
|
||||||
summary: '获取封面(旧版 query 风格)',
|
summary: '获取封面(旧版 query 风格)',
|
||||||
tags: ['歌曲资源'],
|
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
queryParams: {
|
tags: ['歌曲资源'],
|
||||||
source: { ...pSource('pic'), required: true },
|
querystring: qs({
|
||||||
id: { required: true, description: '平台歌曲 id' },
|
source: pSource('pic'),
|
||||||
albumId: { description: '专辑 id' },
|
id: pId,
|
||||||
|
albumId: { type: 'string', description: '专辑 id' },
|
||||||
|
}, ['source', 'id']),
|
||||||
|
response: responses('PicResult'),
|
||||||
},
|
},
|
||||||
dataSchema: 'PicResult',
|
}, picHandler)
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ query }) => picHandler({
|
|
||||||
source: query.source, id: query.id, albumId: query.albumId,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ---------- 榜单 ----------
|
// ---------- 榜单 ----------
|
||||||
|
|
||||||
const boardsHandler = (source) => {
|
fastify.get('/boards/:source', {
|
||||||
const m = getSource(musicSdk, source, 'leaderboard')
|
schema: {
|
||||||
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/boards/:source', {
|
|
||||||
summary: '获取榜单列表',
|
summary: '获取榜单列表',
|
||||||
tags: ['榜单'],
|
tags: ['榜单'],
|
||||||
pathParams: { source: pSource('boards') },
|
params: params({ source: pSource('boards') }),
|
||||||
dataSchema: 'BoardListResult',
|
response: responses('BoardListResult'),
|
||||||
possible404: true,
|
},
|
||||||
possible502: true,
|
}, async(request) => {
|
||||||
}, ({ params }) => boardsHandler(params.source))
|
const m = getSource(musicSdk, request.params.source, 'leaderboard')
|
||||||
|
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
|
||||||
router.get('/boards', {
|
|
||||||
summary: '获取榜单列表(旧版 query 风格)',
|
|
||||||
tags: ['榜单'],
|
|
||||||
deprecated: true,
|
|
||||||
queryParams: { source: { ...pSource('boards'), required: true } },
|
|
||||||
dataSchema: 'BoardListResult',
|
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ query }) => {
|
|
||||||
required(query, ['source'])
|
|
||||||
return boardsHandler(query.source)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const boardHandler = ({ source, id, page }) => {
|
fastify.get('/boards', {
|
||||||
|
schema: {
|
||||||
|
summary: '获取榜单列表(旧版 query 风格)',
|
||||||
|
deprecated: true,
|
||||||
|
tags: ['榜单'],
|
||||||
|
querystring: qs({ source: pSource('boards') }, ['source']),
|
||||||
|
response: responses('BoardListResult'),
|
||||||
|
},
|
||||||
|
}, async(request) => {
|
||||||
|
const m = getSource(musicSdk, request.query.source, 'leaderboard')
|
||||||
|
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const boardHandler = (source, id, page) => {
|
||||||
const m = getSource(musicSdk, source, 'leaderboard')
|
const m = getSource(musicSdk, source, 'leaderboard')
|
||||||
return m.leaderboard.getList(id, page).catch(err => { throw upstream(err.message) })
|
// 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化
|
||||||
|
const boardId = String(id).replace(new RegExp(`^${source}__`), '')
|
||||||
|
return m.leaderboard.getList(boardId, page).catch(err => { throw upstream(err.message) })
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/boards/:source/:boardId', {
|
fastify.get('/boards/:source/:boardId', {
|
||||||
|
schema: {
|
||||||
summary: '获取榜单详情',
|
summary: '获取榜单详情',
|
||||||
tags: ['榜单'],
|
tags: ['榜单'],
|
||||||
pathParams: {
|
params: params({
|
||||||
source: pSource('boards'),
|
source: pSource('boards'),
|
||||||
boardId: { description: '榜单 id(来自榜单列表接口)', example: 'tx__26' },
|
boardId: { type: 'string', description: '榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)', example: 'kw__93' },
|
||||||
|
}),
|
||||||
|
querystring: qs({ page: pPage }),
|
||||||
|
response: responses('BoardDetailResult'),
|
||||||
},
|
},
|
||||||
queryParams: { page: pPage },
|
}, async(request) => boardHandler(
|
||||||
dataSchema: 'BoardDetailResult',
|
request.params.source, request.params.boardId, request.query.page,
|
||||||
possible404: true,
|
))
|
||||||
possible502: true,
|
|
||||||
}, ({ params, query }) => boardHandler({
|
|
||||||
source: params.source, id: params.boardId, page: toInt(query.page, 'page', { def: 1 }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/board', {
|
fastify.get('/board', {
|
||||||
|
schema: {
|
||||||
summary: '获取榜单详情(旧版 query 风格)',
|
summary: '获取榜单详情(旧版 query 风格)',
|
||||||
tags: ['榜单'],
|
|
||||||
deprecated: true,
|
deprecated: true,
|
||||||
queryParams: {
|
tags: ['榜单'],
|
||||||
source: { ...pSource('boards'), required: true },
|
querystring: qs({
|
||||||
id: { required: true, description: '榜单 id' },
|
source: pSource('boards'),
|
||||||
|
id: { type: 'string', description: '榜单 id' },
|
||||||
page: pPage,
|
page: pPage,
|
||||||
|
}, ['source', 'id']),
|
||||||
|
response: responses('BoardDetailResult'),
|
||||||
},
|
},
|
||||||
dataSchema: 'BoardDetailResult',
|
}, async(request) => boardHandler(
|
||||||
possible404: true,
|
request.query.source, request.query.id, request.query.page,
|
||||||
possible502: true,
|
))
|
||||||
}, ({ query }) => {
|
|
||||||
required(query, ['source', 'id'])
|
|
||||||
return boardHandler({
|
|
||||||
source: query.source, id: query.id, page: toInt(query.page, 'page', { def: 1 }),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- 歌单 & 热搜 ----------
|
// ---------- 歌单 & 热搜 ----------
|
||||||
|
|
||||||
const hotSearchHandler = (source) => {
|
fastify.get('/hot-search/:source', {
|
||||||
const m = getSource(musicSdk, source, 'hotSearch')
|
schema: {
|
||||||
return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get('/hot-search/:source', {
|
|
||||||
summary: '获取热搜词',
|
summary: '获取热搜词',
|
||||||
tags: ['歌单'],
|
tags: ['歌单'],
|
||||||
pathParams: { source: pSource('hotSearch') },
|
params: params({ source: pSource('hotSearch') }),
|
||||||
dataSchema: 'HotSearchResult',
|
response: responses('HotSearchResult'),
|
||||||
possible404: true,
|
},
|
||||||
possible502: true,
|
}, async(request) => {
|
||||||
}, ({ params }) => hotSearchHandler(params.source))
|
const m = getSource(musicSdk, request.params.source, 'hotSearch')
|
||||||
|
return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
|
||||||
router.get('/hotSearch', {
|
|
||||||
summary: '获取热搜词(旧版 query 风格)',
|
|
||||||
tags: ['歌单'],
|
|
||||||
deprecated: true,
|
|
||||||
queryParams: { source: { ...pSource('hotSearch'), required: true } },
|
|
||||||
dataSchema: 'HotSearchResult',
|
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ query }) => {
|
|
||||||
required(query, ['source'])
|
|
||||||
return hotSearchHandler(query.source)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const songListHandler = ({ source, page, sortId, tagId }) => {
|
fastify.get('/hotSearch', {
|
||||||
|
schema: {
|
||||||
|
summary: '获取热搜词(旧版 query 风格)',
|
||||||
|
deprecated: true,
|
||||||
|
tags: ['歌单'],
|
||||||
|
querystring: qs({ source: pSource('hotSearch') }, ['source']),
|
||||||
|
response: responses('HotSearchResult'),
|
||||||
|
},
|
||||||
|
}, async(request) => {
|
||||||
|
const m = getSource(musicSdk, request.query.source, 'hotSearch')
|
||||||
|
return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
|
||||||
|
})
|
||||||
|
|
||||||
|
const songListHandler = (source, page, sortId, tagId) => {
|
||||||
const m = getSource(musicSdk, source, 'songList')
|
const m = getSource(musicSdk, source, 'songList')
|
||||||
return m.songList.getList(sortId ?? 3, tagId ?? 0, page).catch(err => { throw upstream(err.message) })
|
// sortId 各平台词汇不同(kw 'new'、tx 5、wy 'hot'…),省略时用平台自己声明的默认排序
|
||||||
|
return m.songList.getList(sortId ?? m.songList.sortList?.[0]?.id, tagId, page).catch(err => { throw upstream(err.message) })
|
||||||
}
|
}
|
||||||
|
|
||||||
router.get('/song-list/:source', {
|
fastify.get('/song-list/:source', {
|
||||||
|
schema: {
|
||||||
summary: '获取歌单列表',
|
summary: '获取歌单列表',
|
||||||
tags: ['歌单'],
|
tags: ['歌单'],
|
||||||
pathParams: { source: pSource('songList') },
|
params: params({ source: pSource('songList') }),
|
||||||
queryParams: {
|
querystring: qs({
|
||||||
page: pPage,
|
page: pPage,
|
||||||
sortId: { type: 'integer', default: 3, description: '排序 id(平台相关)' },
|
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' },
|
||||||
tagId: { type: 'integer', default: 0, description: '标签 id,0 为全部' },
|
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' },
|
||||||
|
}),
|
||||||
|
response: responses('SongListResult'),
|
||||||
},
|
},
|
||||||
dataSchema: 'SongListResult',
|
}, async(request) => {
|
||||||
possible404: true,
|
const { page, sortId, tagId } = request.query
|
||||||
possible502: true,
|
return songListHandler(request.params.source, page, sortId, tagId)
|
||||||
}, ({ params, query }) => songListHandler({
|
|
||||||
source: params.source,
|
|
||||||
page: toInt(query.page, 'page', { def: 1 }),
|
|
||||||
sortId: query.sortId === undefined ? undefined : toInt(query.sortId, 'sortId', { min: 0 }),
|
|
||||||
tagId: query.tagId === undefined ? undefined : toInt(query.tagId, 'tagId', { min: 0 }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/songList', {
|
|
||||||
summary: '获取歌单列表(旧版 query 风格)',
|
|
||||||
tags: ['歌单'],
|
|
||||||
deprecated: true,
|
|
||||||
queryParams: {
|
|
||||||
source: { ...pSource('songList'), required: true },
|
|
||||||
page: pPage,
|
|
||||||
sortId: { type: 'integer', default: 3, description: '排序 id(平台相关)' },
|
|
||||||
tagId: { type: 'integer', default: 0, description: '标签 id' },
|
|
||||||
},
|
|
||||||
dataSchema: 'SongListResult',
|
|
||||||
possible404: true,
|
|
||||||
possible502: true,
|
|
||||||
}, ({ query }) => {
|
|
||||||
required(query, ['source'])
|
|
||||||
return songListHandler({
|
|
||||||
source: query.source,
|
|
||||||
page: toInt(query.page, 'page', { def: 1 }),
|
|
||||||
sortId: query.sortId === undefined ? undefined : toInt(query.sortId, 'sortId', { min: 0 }),
|
|
||||||
tagId: query.tagId === undefined ? undefined : toInt(query.tagId, 'tagId', { min: 0 }),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fastify.get('/songList', {
|
||||||
|
schema: {
|
||||||
|
summary: '获取歌单列表(旧版 query 风格)',
|
||||||
|
deprecated: true,
|
||||||
|
tags: ['歌单'],
|
||||||
|
querystring: qs({
|
||||||
|
source: pSource('songList'),
|
||||||
|
page: pPage,
|
||||||
|
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,省略时用平台默认)' },
|
||||||
|
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串)' },
|
||||||
|
}, ['source']),
|
||||||
|
response: responses('SongListResult'),
|
||||||
|
},
|
||||||
|
}, async(request) => {
|
||||||
|
const { source, page, sortId, tagId } = request.query
|
||||||
|
return songListHandler(source, page, sortId, tagId)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- 系统 ----------
|
// ---------- 系统 ----------
|
||||||
|
|
||||||
router.get('/health', {
|
fastify.get('/health', {
|
||||||
|
schema: {
|
||||||
summary: '健康检查',
|
summary: '健康检查',
|
||||||
tags: ['系统'],
|
tags: ['系统'],
|
||||||
dataSchema: 'HealthResult',
|
response: responses('HealthResult', { notFoundDoc: false, upstreamDoc: false }),
|
||||||
}, () => ({
|
},
|
||||||
|
}, async() => ({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
uptime: Math.round(process.uptime()),
|
uptime: Math.round(process.uptime()),
|
||||||
platforms: musicSdk.sources.length,
|
platforms: musicSdk.sources.length,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
router.get('/sources', {
|
fastify.get('/sources', {
|
||||||
|
schema: {
|
||||||
summary: '获取支持的平台',
|
summary: '获取支持的平台',
|
||||||
description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。',
|
description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。',
|
||||||
tags: ['系统'],
|
tags: ['系统'],
|
||||||
dataSchema: 'SourcesResult',
|
response: responses('SourcesResult', { notFoundDoc: false, upstreamDoc: false }),
|
||||||
}, () => ({
|
},
|
||||||
|
}, async() => ({
|
||||||
platforms: musicSdk.sources.map(({ id, name }) => {
|
platforms: musicSdk.sources.map(({ id, name }) => {
|
||||||
const m = musicSdk[id] || {}
|
const m = musicSdk[id] || {}
|
||||||
const qualities = [...new Set(
|
const qualities = [...new Set(
|
||||||
|
|||||||
+141
-126
@@ -1,5 +1,5 @@
|
|||||||
// HTTP 服务器入口 —— 多平台音乐聚合 API
|
// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API
|
||||||
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档
|
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动)
|
||||||
//
|
//
|
||||||
// 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容)
|
// 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容)
|
||||||
// GET /search (同左)
|
// GET /search (同左)
|
||||||
@@ -16,168 +16,183 @@
|
|||||||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||||||
// 响应信封:成功 { code: 0, data };失败 { code: <HTTP状态码>, msg, detail? }
|
// 响应信封:成功 { code: 0, data };失败 { code: <HTTP状态码>, msg, detail? }
|
||||||
|
|
||||||
import http from 'node:http'
|
import Fastify from 'fastify'
|
||||||
|
import cors from '@fastify/cors'
|
||||||
|
import swagger from '@fastify/swagger'
|
||||||
|
import swaggerUi from '@fastify/swagger-ui'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { dirname, join } from 'node:path'
|
import { dirname, join } from 'node:path'
|
||||||
import { URL } from 'node:url'
|
|
||||||
import musicSdk from '../musicSdk/index.js'
|
import musicSdk from '../musicSdk/index.js'
|
||||||
import { Router } from './http/router.js'
|
|
||||||
import { ApiError } from './http/api-error.js'
|
import { ApiError } from './http/api-error.js'
|
||||||
import { registerRoutes } from './routes.js'
|
import { musicRoutes } from './routes.js'
|
||||||
import { buildOpenApi } from './openapi/spec.js'
|
import { COMPONENT_SCHEMAS } from './openapi/components.js'
|
||||||
import { swaggerHtml, redocHtml } from './openapi/ui.js'
|
import { redocHtml } from './openapi/ui.js'
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3300
|
// 默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间
|
||||||
|
const PORT = process.env.PORT || 16688
|
||||||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
||||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。'
|
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。'
|
||||||
|
|
||||||
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
||||||
|
|
||||||
// ---- 路由装配 ----
|
const fastify = Fastify({
|
||||||
|
logger: false,
|
||||||
|
// schema 中的 example 等文档关键字仅供 OpenAPI 使用,关闭 Ajv 严格模式以免校验构建报错
|
||||||
|
ajv: { customOptions: { strict: false } },
|
||||||
|
})
|
||||||
|
fastify.decorate('musicSdk', musicSdk)
|
||||||
|
|
||||||
const router = new Router()
|
// ---- 共享 schema(领域模型:校验引用与 OpenAPI components 同源) ----
|
||||||
registerRoutes(router, musicSdk)
|
for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) {
|
||||||
|
fastify.addSchema({ ...schema, $id: name })
|
||||||
|
}
|
||||||
|
|
||||||
let specCache = null
|
// ---- 全局中间行为 ----
|
||||||
const openApiSpec = () => {
|
|
||||||
if (!specCache) specCache = buildOpenApi(router, {
|
// 收集全部路由模式(含 :param 参数段),供 not-found 处理器做 405 判定
|
||||||
|
const routePatterns = []
|
||||||
|
fastify.addHook('onRoute', (routeOptions) => {
|
||||||
|
if (!routeOptions.url || !routeOptions.method) return
|
||||||
|
// :param 段 → [^/]+;通配符 * → .*(如 @fastify/cors 注册的 catch-all 路由);其余按字面量转义
|
||||||
|
const source = routeOptions.url
|
||||||
|
.replace(/:[^/]+/g, '\u0000')
|
||||||
|
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
.replace(/\u0000/g, '[^/]+')
|
||||||
|
.replace(/\\\*/g, '.*')
|
||||||
|
routePatterns.push({ method: routeOptions.method, regex: new RegExp(`^${source}$`) })
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.register(cors, { origin: '*' })
|
||||||
|
|
||||||
|
fastify.addHook('onRequest', async(_request, reply) => {
|
||||||
|
reply.header('Cache-Control', 'no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.addHook('onResponse', async(request, reply) => {
|
||||||
|
console.log(`[${new Date().toISOString()}] ${request.method} ${request.url} → ${reply.statusCode} (${Math.round(reply.elapsedTime)}ms)`)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 成功响应统一包成 { code: 0, data };文档页/raw 路由与非 200 响应不包
|
||||||
|
fastify.addHook('preSerialization', async(request, reply, payload) => {
|
||||||
|
if (reply.statusCode !== 200) return payload
|
||||||
|
if (request.routeOptions.config?.raw) return payload
|
||||||
|
if (payload === null || typeof payload === 'object') return { code: 0, data: payload }
|
||||||
|
return payload
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- 错误处理 ----
|
||||||
|
|
||||||
|
fastify.setErrorHandler((err, request, reply) => {
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
const payload = { code: err.status, msg: err.message }
|
||||||
|
if (err.detail !== undefined) payload.detail = err.detail
|
||||||
|
return reply.code(err.status).send(payload)
|
||||||
|
}
|
||||||
|
// JSON Schema 校验失败(FastAPI 同款 422 语义)
|
||||||
|
if (err.validation) {
|
||||||
|
return reply.code(422).send({ code: 422, msg: err.message, detail: err.validation })
|
||||||
|
}
|
||||||
|
console.error(`[${new Date().toISOString()}] ${request.method} ${request.url} 内部错误:`, err)
|
||||||
|
return reply.code(500).send({ code: 500, msg: '服务器内部错误' })
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.setNotFoundHandler((request, reply) => {
|
||||||
|
// 路径存在但方法不允许 → 405 + Allow 头(Fastify 默认给 404,这里补齐标准语义)
|
||||||
|
// 用模式匹配而非 hasRoute:hasRoute 需要精确 URL,匹配不到 /url/:source/:id 这类参数路由
|
||||||
|
const pathname = new URL(request.url, 'http://localhost').pathname
|
||||||
|
const allowed = [...new Set(
|
||||||
|
routePatterns
|
||||||
|
.filter(r => r.regex.test(pathname) && r.method !== request.method)
|
||||||
|
// OPTIONS/HEAD 是辅助方法(CORS 预检由 @fastify/cors 的通配路由承接),不应计入 405 判定
|
||||||
|
.filter(r => r.method !== 'OPTIONS' && r.method !== 'HEAD')
|
||||||
|
.map(r => r.method),
|
||||||
|
)]
|
||||||
|
if (allowed.length) {
|
||||||
|
reply.code(405).header('Allow', allowed.join(', '))
|
||||||
|
return reply.send({ code: 405, msg: `方法 ${request.method} 不允许,请使用 ${allowed.join(', ')}` })
|
||||||
|
}
|
||||||
|
reply.code(404).send({ code: 404, msg: `接口不存在: ${request.url}` })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- OpenAPI 文档 ----
|
||||||
|
|
||||||
|
fastify.register(swagger, {
|
||||||
|
// 组件按 addSchema 的 $id 命名(默认 def-1/def-2 不可读)
|
||||||
|
refResolver: {
|
||||||
|
buildLocalReference: (json, _baseUri, _fragment, i) => json.$id || `def-${i}`,
|
||||||
|
},
|
||||||
|
openapi: {
|
||||||
|
openapi: '3.1.0',
|
||||||
|
info: {
|
||||||
title: API_TITLE,
|
title: API_TITLE,
|
||||||
description: API_DESCRIPTION,
|
description: API_DESCRIPTION,
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
})
|
},
|
||||||
return specCache
|
servers: [{ url: '/', description: '当前服务器' }],
|
||||||
}
|
tags: [
|
||||||
|
{ name: '搜索', description: '歌曲搜索与跨平台匹配' },
|
||||||
|
{ name: '歌曲资源', description: '播放地址、歌词、封面' },
|
||||||
|
{ name: '榜单', description: '排行榜' },
|
||||||
|
{ name: '歌单', description: '歌单与热搜' },
|
||||||
|
{ name: '系统', description: '健康检查、平台信息、文档' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
router.get('/', {
|
fastify.register(swaggerUi, {
|
||||||
summary: 'API 文档入口',
|
routePrefix: '/docs',
|
||||||
description: '302 重定向到 Swagger UI 文档页。',
|
url: '/openapi.json',
|
||||||
tags: ['系统'],
|
})
|
||||||
rawResponses: { '302': { description: '重定向到 /docs' } },
|
|
||||||
}, () => ({ __raw: true, status: 302, headers: { Location: '/docs' }, body: '' }))
|
|
||||||
|
|
||||||
router.get('/docs', {
|
fastify.get('/openapi.json', {
|
||||||
summary: 'Swagger UI 交互文档',
|
config: { raw: true },
|
||||||
description: '基于 OpenAPI 规范的交互式文档,可直接在页面上调试接口。',
|
schema: {
|
||||||
tags: ['系统'],
|
|
||||||
rawResponses: { '200': { description: 'Swagger UI 页面' } },
|
|
||||||
}, () => ({
|
|
||||||
__raw: true,
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
body: swaggerHtml({ title: API_TITLE }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/redoc', {
|
|
||||||
summary: 'ReDoc 阅读版文档',
|
|
||||||
tags: ['系统'],
|
|
||||||
rawResponses: { '200': { description: 'ReDoc 页面' } },
|
|
||||||
}, () => ({
|
|
||||||
__raw: true,
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
||||||
body: redocHtml({ title: API_TITLE }),
|
|
||||||
}))
|
|
||||||
|
|
||||||
router.get('/openapi.json', {
|
|
||||||
summary: 'OpenAPI 3.1 规范文档',
|
summary: 'OpenAPI 3.1 规范文档',
|
||||||
description: '机器可读的接口描述,可用于生成各语言客户端。',
|
description: '机器可读的接口描述,可用于生成各语言客户端。',
|
||||||
tags: ['系统'],
|
tags: ['系统'],
|
||||||
rawResponses: { '200': { description: 'OpenAPI 规范(JSON)' } },
|
},
|
||||||
}, () => ({
|
}, async() => fastify.swagger())
|
||||||
__raw: true,
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
||||||
body: JSON.stringify(openApiSpec(), null, 2),
|
|
||||||
}))
|
|
||||||
|
|
||||||
// ---- HTTP 处理 ----
|
fastify.get('/redoc', {
|
||||||
|
schema: {
|
||||||
|
summary: 'ReDoc 阅读版文档',
|
||||||
|
tags: ['系统'],
|
||||||
|
},
|
||||||
|
}, async(_request, reply) => reply.type('text/html; charset=utf-8').send(redocHtml({ title: API_TITLE })))
|
||||||
|
|
||||||
const sendJson = (res, status, payload) => {
|
fastify.get('/', {
|
||||||
if (res.writableEnded) return
|
schema: {
|
||||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
summary: 'API 文档入口',
|
||||||
res.end(JSON.stringify(payload))
|
description: '302 重定向到 Swagger UI 文档页。',
|
||||||
}
|
tags: ['系统'],
|
||||||
|
},
|
||||||
|
}, async(_request, reply) => reply.redirect('/docs'))
|
||||||
|
|
||||||
const logRequest = (req, pathname, search, status, ms) => {
|
// ---- 业务路由 ----
|
||||||
console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}${search} → ${status} (${ms}ms)`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = async(req, res) => {
|
fastify.register(musicRoutes)
|
||||||
const started = Date.now()
|
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
||||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
||||||
res.setHeader('Cache-Control', 'no-store')
|
|
||||||
|
|
||||||
if (req.method === 'OPTIONS') {
|
export const startServer = async(port = PORT) => {
|
||||||
res.writeHead(204)
|
await fastify.listen({ port, host: '0.0.0.0' })
|
||||||
res.end()
|
|
||||||
logRequest(req, req.url, '', 204, Date.now() - started)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
|
||||||
const query = Object.fromEntries(url.searchParams.entries())
|
|
||||||
let status = 500
|
|
||||||
|
|
||||||
try {
|
|
||||||
const matched = router.match(url.pathname, req.method)
|
|
||||||
if (!matched) throw new ApiError(404, `接口不存在: ${url.pathname}`)
|
|
||||||
if (matched.allowed) {
|
|
||||||
res.setHeader('Allow', matched.allowed.join(', '))
|
|
||||||
throw new ApiError(405, `方法 ${req.method} 不允许,请使用 ${matched.allowed.join(', ')}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = await matched.handler({ req, res, params: matched.params, query })
|
|
||||||
if (out && out.__raw) {
|
|
||||||
status = out.status || 200
|
|
||||||
res.writeHead(status, out.headers)
|
|
||||||
res.end(out.body)
|
|
||||||
} else {
|
|
||||||
status = 200
|
|
||||||
sendJson(res, status, { code: 0, data: out })
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
status = err.status
|
|
||||||
const payload = { code: err.status, msg: err.message }
|
|
||||||
if (err.detail !== undefined) payload.detail = err.detail
|
|
||||||
sendJson(res, status, payload)
|
|
||||||
} else {
|
|
||||||
status = 500
|
|
||||||
console.error(`[${new Date().toISOString()}] ${req.method} ${url.pathname} 内部错误:`, err)
|
|
||||||
sendJson(res, status, { code: 500, msg: '服务器内部错误' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logRequest(req, url.pathname, url.search, status, Date.now() - started)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const startServer = (port = PORT) => {
|
|
||||||
const server = http.createServer(handler)
|
|
||||||
server.listen(port, () => {
|
|
||||||
console.log(`音乐服务器已启动: http://localhost:${port}`)
|
console.log(`音乐服务器已启动: http://localhost:${port}`)
|
||||||
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
|
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
|
||||||
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
|
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
|
||||||
console.log(`OpenAPI: http://localhost:${port}/openapi.json`)
|
console.log(`OpenAPI: http://localhost:${port}/openapi.json`)
|
||||||
})
|
return fastify
|
||||||
|
}
|
||||||
|
|
||||||
const shutdown = (signal) => {
|
const shutdown = (signal) => {
|
||||||
console.log(`\n收到 ${signal},正在关闭服务器...`)
|
console.log(`\n收到 ${signal},正在关闭服务器...`)
|
||||||
server.close(() => {
|
fastify.close().then(() => {
|
||||||
console.log('服务器已关闭')
|
console.log('服务器已关闭')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
server.closeAllConnections?.()
|
|
||||||
setTimeout(() => process.exit(0), 3000).unref()
|
setTimeout(() => process.exit(0), 3000).unref()
|
||||||
}
|
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
||||||
|
|
||||||
return server
|
|
||||||
}
|
}
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||||
|
|
||||||
// 直接运行时启动
|
// 直接运行时启动
|
||||||
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) {
|
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) {
|
||||||
|
|||||||
+51
-5
@@ -1,13 +1,15 @@
|
|||||||
// 冒烟测试 —— 验证服务器各接口与各平台功能
|
// 冒烟测试 —— 验证服务器各接口与各平台功能
|
||||||
// 运行:node src/smoke-test.js
|
// 运行:node src/smoke-test.js
|
||||||
//
|
//
|
||||||
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词(kw)、封面、榜单、热搜、
|
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
|
||||||
// 标准 HTTP 语义(404/405/422/302)、文档(/docs /openapi.json /sources)
|
// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、文档页
|
||||||
|
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
|
|
||||||
const BASE = process.env.BASE_URL || 'http://localhost:3300'
|
// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖
|
||||||
|
const PORT = process.env.PORT || 16688
|
||||||
|
const BASE = process.env.BASE_URL || `http://localhost:${PORT}`
|
||||||
const results = []
|
const results = []
|
||||||
|
|
||||||
const get = (path) => new Promise((resolve, reject) => {
|
const get = (path) => new Promise((resolve, reject) => {
|
||||||
@@ -69,6 +71,7 @@ const isServerRunning = () => new Promise(resolve => {
|
|||||||
const startLocalServer = () => new Promise((resolve, reject) => {
|
const startLocalServer = () => new Promise((resolve, reject) => {
|
||||||
const proc = spawn(process.execPath, ['src/server.js'], {
|
const proc = spawn(process.execPath, ['src/server.js'], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
|
env: { ...process.env, PORT: String(PORT) },
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
})
|
})
|
||||||
let stdout = ''
|
let stdout = ''
|
||||||
@@ -208,6 +211,42 @@ await test('榜单 路径风格 /boards/:source', async() => {
|
|||||||
return `${r.data.list.length} 个榜单`
|
return `${r.data.list.length} 个榜单`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test('封面 路径风格 /pic/kw/:id', async() => {
|
||||||
|
const r = await get('/pic/kw/28423286')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
return r.data.url.slice(0, 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
|
||||||
|
// 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid)
|
||||||
|
const r = await get('/boards/kw/kw__93?page=1')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.list?.length) throw new Error('空列表')
|
||||||
|
return `${r.data.list.length} 首`
|
||||||
|
})
|
||||||
|
|
||||||
|
await test('跨平台匹配 /find', async() => {
|
||||||
|
const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`)
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.list?.length) throw new Error('空列表')
|
||||||
|
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
|
||||||
|
})
|
||||||
|
|
||||||
|
await test('歌单 路径风格 /song-list/:source', async() => {
|
||||||
|
const r = await get('/song-list/kg?page=1')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.list?.length) throw new Error('空列表')
|
||||||
|
return `${r.data.list.length} 个歌单`
|
||||||
|
})
|
||||||
|
|
||||||
|
await test('歌单 默认排序 /song-list/kw', async() => {
|
||||||
|
const r = await get('/song-list/kw?page=1')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.list?.length) throw new Error('空列表(sortId 默认值未适配平台)')
|
||||||
|
return `${r.data.list.length} 个歌单`
|
||||||
|
})
|
||||||
|
|
||||||
await test('参数校验 422(缺少 name)', async() => {
|
await test('参数校验 422(缺少 name)', async() => {
|
||||||
const r = await getRaw('/search')
|
const r = await getRaw('/search')
|
||||||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||||||
@@ -228,13 +267,20 @@ await test('404 未知接口', async() => {
|
|||||||
return JSON.parse(r.body).msg
|
return JSON.parse(r.body).msg
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('405 方法不允许', async() => {
|
await test('405 方法不允许(静态路由)', async() => {
|
||||||
const r = await postRaw('/search')
|
const r = await postRaw('/search')
|
||||||
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
||||||
if (!r.headers.allow) throw new Error('缺少 Allow 头')
|
if (!r.headers.allow) throw new Error('缺少 Allow 头')
|
||||||
return `Allow: ${r.headers.allow}`
|
return `Allow: ${r.headers.allow}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test('405 方法不允许(参数路由)', async() => {
|
||||||
|
const r = await postRaw('/url/kw/28423286')
|
||||||
|
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
|
||||||
|
if (!r.headers.allow || !r.headers.allow.includes('GET')) throw new Error(`Allow 头异常: ${r.headers.allow}`)
|
||||||
|
return `Allow: ${r.headers.allow}`
|
||||||
|
})
|
||||||
|
|
||||||
await test('根路径 302 → /docs', async() => {
|
await test('根路径 302 → /docs', async() => {
|
||||||
const r = await getRaw('/')
|
const r = await getRaw('/')
|
||||||
if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`)
|
if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`)
|
||||||
@@ -255,7 +301,7 @@ await test('OpenAPI 规范 /openapi.json', async() => {
|
|||||||
const spec = JSON.parse(r.body)
|
const spec = JSON.parse(r.body)
|
||||||
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
|
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
|
||||||
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义')
|
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义')
|
||||||
if (!spec.paths?.['/url/:source/:id']) throw new Error('缺少路径参数路由定义')
|
if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义')
|
||||||
return `${Object.keys(spec.paths).length} 个路径`
|
return `${Object.keys(spec.paths).length} 个路径`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
// 临时验证脚本 —— 检查 OpenAPI 规范与文档页产出
|
|
||||||
import { startServer } from '../src/server.js'
|
|
||||||
|
|
||||||
const server = startServer(3459)
|
|
||||||
await new Promise(r => setTimeout(r, 500))
|
|
||||||
|
|
||||||
const spec = await fetch('http://localhost:3459/openapi.json').then(r => r.json())
|
|
||||||
const urlRoute = spec.paths['/url/:source/:id'].get
|
|
||||||
console.log('openapi:', spec.openapi, '| paths:', Object.keys(spec.paths).length)
|
|
||||||
console.log('url params:', JSON.stringify(urlRoute.parameters.map(p => `${p.name}:${p.in}${p.required ? '(required)' : ''}`)))
|
|
||||||
console.log('url responses:', Object.keys(urlRoute.responses).join(', '))
|
|
||||||
console.log('deprecated routes:', Object.entries(spec.paths).filter(([, v]) => v.get.deprecated).map(([k]) => k).join(', '))
|
|
||||||
console.log('schemas:', Object.keys(spec.components.schemas).length)
|
|
||||||
|
|
||||||
const html = await fetch('http://localhost:3459/docs').then(r => r.text())
|
|
||||||
console.log('docs page ok:', html.includes('swagger-ui-bundle') && html.includes('/openapi.json'))
|
|
||||||
const redoc = await fetch('http://localhost:3459/redoc').then(r => r.text())
|
|
||||||
console.log('redoc page ok:', redoc.includes('redoc'))
|
|
||||||
|
|
||||||
const sources = await fetch('http://localhost:3459/sources').then(r => r.json())
|
|
||||||
console.log('sources:', JSON.stringify(sources.data.platforms.map(p => `${p.id}:${p.qualities.join('/')}`)))
|
|
||||||
console.log('kw caps:', JSON.stringify(sources.data.platforms.find(p => p.id === 'kw').capabilities))
|
|
||||||
|
|
||||||
server.close()
|
|
||||||
process.exit(0)
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
// lx-music-source-v5 复现/追踪脚本(与生产加载器 src/apis/lx-source-ivm.js 相互独立)
|
|
||||||
// 1) 将去混淆脚本末尾的字符串缓存 [] 替换为 LOGCACHE(Proxy),按序记录 JSVMP 解码的全部字符串
|
|
||||||
// 2) 模拟 lx-music-desktop preload.js 的 globalThis.lx 环境,观察脚本的注册与取流行为
|
|
||||||
// 运行:node tmp/reproduce-lx-source-v5.mjs
|
|
||||||
import crypto from 'node:crypto'
|
|
||||||
import fs from 'node:fs'
|
|
||||||
import vm from 'node:vm'
|
|
||||||
import zlib from 'node:zlib'
|
|
||||||
import { promisify } from 'node:util'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
|
|
||||||
const inflate = promisify(zlib.inflate)
|
|
||||||
const deflate = promisify(zlib.deflate)
|
|
||||||
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex')
|
|
||||||
|
|
||||||
const deobPath = fileURLToPath(new URL('../lx-music-source-v5-deobfuscated.js', import.meta.url))
|
|
||||||
const obfPath = fileURLToPath(new URL('../lx-music-source-v5.js', import.meta.url))
|
|
||||||
|
|
||||||
let code = fs.readFileSync(deobPath, 'utf8')
|
|
||||||
|
|
||||||
// 字符串缓存:文件末尾 IIFE 最后一个参数 []
|
|
||||||
const cacheLog = []
|
|
||||||
const LOGCACHE = new Proxy([], {
|
|
||||||
set(t, prop, val) {
|
|
||||||
if (typeof val === 'string') cacheLog.push(val)
|
|
||||||
t[prop] = val
|
|
||||||
return true
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const idx = code.lastIndexOf(', []);')
|
|
||||||
code = code.slice(0, idx) + ', LOGCACHE);'
|
|
||||||
|
|
||||||
// ---- lx 环境(复刻 preload.js) ----
|
|
||||||
const handlers = {}
|
|
||||||
const requestCalls = []
|
|
||||||
let rconfigBody = process.env.RCONFIG_BODY ?? JSON.stringify({ code: 0, msg: 'success', data: 'https://example.test/fake.mp3' })
|
|
||||||
const lx = {
|
|
||||||
EVENT_NAMES: { request: 'request', inited: 'inited', updateAlert: 'updateAlert' },
|
|
||||||
request(url, options = {}, callback) {
|
|
||||||
const rec = { url, options: { method: options.method ?? 'get', timeout: options.timeout, headers: options.headers, body: options.body, form: options.form, formData: options.formData } }
|
|
||||||
requestCalls.push(rec)
|
|
||||||
console.log('[lx.request]', url)
|
|
||||||
console.log(' options:', JSON.stringify(rec.options))
|
|
||||||
const isRconfig = url.includes('/rconfig')
|
|
||||||
const raw = isRconfig ? rconfigBody : JSON.stringify({ code: 0, msg: 'success', data: 'https://example.test/fake.mp3' })
|
|
||||||
const parsed = (() => { try { return JSON.parse(raw) } catch { return raw } })()
|
|
||||||
setTimeout(() => {
|
|
||||||
callback(null, { statusCode: 200, statusMessage: 'OK', headers: { 'content-type': 'application/json' }, raw: Buffer.from(raw), body: parsed }, parsed)
|
|
||||||
}, 5)
|
|
||||||
return () => {}
|
|
||||||
},
|
|
||||||
send(eventName, data) {
|
|
||||||
console.log('[lx.send]', eventName, JSON.stringify(data))
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
on(eventName, handler) {
|
|
||||||
console.log('[lx.on]', eventName, typeof handler, handler && handler.length)
|
|
||||||
handlers[eventName] = handler
|
|
||||||
return Promise.resolve()
|
|
||||||
},
|
|
||||||
utils: {
|
|
||||||
crypto: {
|
|
||||||
aesEncrypt(b, mode, key, iv) { const c = crypto.createCipheriv(mode, key, iv); return Buffer.concat([c.update(b), c.final()]) },
|
|
||||||
rsaEncrypt(b, key) { b = Buffer.concat([Buffer.alloc(128 - b.length), b]); return crypto.publicEncrypt({ key, padding: crypto.constants.RSA_NO_PADDING }, b) },
|
|
||||||
randomBytes: (n) => crypto.randomBytes(n),
|
|
||||||
md5,
|
|
||||||
},
|
|
||||||
buffer: { from: (...a) => Buffer.from(...a), bufToString: (b, f) => Buffer.from(b, 'binary').toString(f) },
|
|
||||||
zlib: { inflate: (b) => inflate(b), deflate: (d) => deflate(d) },
|
|
||||||
},
|
|
||||||
currentScriptInfo: { name: '独家音源', description: '', version: '5', author: 'w', homepage: '', rawScript: code },
|
|
||||||
version: '2.0.0',
|
|
||||||
env: 'desktop',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = {
|
|
||||||
console, setTimeout, clearTimeout, setInterval, clearInterval,
|
|
||||||
Promise, Error, TypeError, RangeError, SyntaxError, EvalError, URIError,
|
|
||||||
Array, Object, String, Number, Boolean, Date, Math, JSON, RegExp, BigInt, Symbol,
|
|
||||||
Function, parseInt, parseFloat, isNaN, isFinite,
|
|
||||||
encodeURIComponent, decodeURIComponent, escape, unescape,
|
|
||||||
Uint8Array, Uint16Array, Uint32Array, Int8Array, Int16Array, Int32Array, Float32Array, Float64Array, ArrayBuffer, DataView, Map, Set, WeakMap, WeakSet,
|
|
||||||
TextEncoder, TextDecoder, URL, URLSearchParams,
|
|
||||||
Buffer,
|
|
||||||
atob: (s) => Buffer.from(s, 'base64').toString('binary'),
|
|
||||||
btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
|
|
||||||
SCRIPT_MD5: md5(fs.readFileSync(obfPath)),
|
|
||||||
LOGCACHE,
|
|
||||||
lx,
|
|
||||||
}
|
|
||||||
ctx.globalThis = ctx
|
|
||||||
ctx.window = ctx
|
|
||||||
ctx.self = ctx
|
|
||||||
ctx.global = ctx
|
|
||||||
|
|
||||||
process.on('uncaughtException', (e) => console.error('[uncaught]', e.stack?.split('\n').slice(0, 6).join('\n')))
|
|
||||||
process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e?.stack?.split('\n').slice(0, 6).join('\n') || e))
|
|
||||||
|
|
||||||
const context = vm.createContext(ctx)
|
|
||||||
console.error('[run]')
|
|
||||||
try {
|
|
||||||
vm.runInContext(code, context, { filename: 'lx-source-v5.js', timeout: 15000 })
|
|
||||||
console.error('[run ok]')
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[vm error]', e.stack?.split('\n').slice(0, 8).join('\n'))
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 1500))
|
|
||||||
|
|
||||||
console.error('\n===== decoded strings (%d) =====', cacheLog.length)
|
|
||||||
console.log(cacheLog.map((s, i) => `${i}\t${JSON.stringify(s)}`).join('\n'))
|
|
||||||
|
|
||||||
console.error('\n===== summary =====')
|
|
||||||
console.error('events registered:', Object.keys(handlers))
|
|
||||||
console.error('requests made:', requestCalls.length)
|
|
||||||
|
|
||||||
// 触发 musicUrl 请求
|
|
||||||
const handler = handlers.request
|
|
||||||
if (handler) {
|
|
||||||
console.error('\n[trigger] musicUrl kw/128k')
|
|
||||||
const markBefore = cacheLog.length
|
|
||||||
try {
|
|
||||||
const p = handler.call(lx, { source: 'kw', action: 'musicUrl', info: { type: '128k', musicInfo: { songmid: '28423286', hash: 'fakehash123', id: 'kw_28423286' } } })
|
|
||||||
console.error('[trigger] handler returned:', p && p.constructor?.name)
|
|
||||||
if (p && typeof p.then === 'function') {
|
|
||||||
const res = await Promise.race([p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, msg: e?.message, name: e?.name })), new Promise((r) => setTimeout(() => r({ timeout: true }), 8000))])
|
|
||||||
console.error('[trigger result]', JSON.stringify(res, null, 1))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('[trigger sync error]', e?.message)
|
|
||||||
}
|
|
||||||
console.error('\n[strings used during trigger]')
|
|
||||||
console.error(cacheLog.slice(markBefore).map((s) => JSON.stringify(s)).join('\n'))
|
|
||||||
await new Promise((r) => setTimeout(r, 500))
|
|
||||||
console.error('\n[requests during trigger]')
|
|
||||||
for (const rc of requestCalls) console.log(rc.url, JSON.stringify(rc.options))
|
|
||||||
}
|
|
||||||
fs.writeFileSync(fileURLToPath(new URL('./trace-strings.json', import.meta.url)), JSON.stringify(cacheLog, null, 1))
|
|
||||||
process.exit(0)
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// isolated-vm 加载器测试(离线,不真实联网)
|
|
||||||
// 运行:node tmp/test-lx-source-ivm.mjs
|
|
||||||
import { createIvmLxSource } from '../src/apis/lx-source-ivm.js'
|
|
||||||
|
|
||||||
let failed = 0
|
|
||||||
const check = (name, cond, detail = '') => {
|
|
||||||
console.log(`${cond ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
||||||
if (!cond) failed++
|
|
||||||
}
|
|
||||||
|
|
||||||
const makeMock = () => {
|
|
||||||
const seenUrls = []
|
|
||||||
const mock = (url) => {
|
|
||||||
seenUrls.push(url)
|
|
||||||
return {
|
|
||||||
promise: Promise.resolve({
|
|
||||||
statusCode: 200, statusMessage: 'OK',
|
|
||||||
headers: { 'content-type': 'application/json' },
|
|
||||||
body: { code: 0, msg: 'success', data: 'https://example.com/fake.mp3' },
|
|
||||||
}),
|
|
||||||
cancelHttp() {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mock.seenUrls = seenUrls
|
|
||||||
return mock
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 常规流程(默认白名单:脚本 apiUrl 同源放行)=====
|
|
||||||
const mock = makeMock()
|
|
||||||
const lx = await createIvmLxSource({ http: mock })
|
|
||||||
|
|
||||||
check('脚本 inited 上报', !!lx.inited, JSON.stringify(lx.inited?.sources ? Object.keys(lx.inited.sources) : null))
|
|
||||||
check('scriptInfo 解析', lx.scriptInfo.version === '5', JSON.stringify(lx.scriptInfo))
|
|
||||||
check('白名单来自脚本声明', lx.allowedOrigins.includes('https://88.lxmusic.xn--fiqs8s'), lx.allowedOrigins.join(','))
|
|
||||||
|
|
||||||
// hash 优先
|
|
||||||
const r1 = lx.apis.kw.getMusicUrl({ songmid: '28423286', hash: 'fakehash123' }, '320k')
|
|
||||||
const u1 = await r1.promise
|
|
||||||
check('musicUrl hash 优先', u1.url === 'https://example.com/fake.mp3', u1.url)
|
|
||||||
check('取流路径/hash', mock.seenUrls.some(u => u.includes('/v4s/url/kw/fakehash123/320k')), mock.seenUrls.at(-1)?.split('?')[0])
|
|
||||||
|
|
||||||
// songmid 兜底
|
|
||||||
const r2 = lx.apis.tx.getMusicUrl({ songmid: '003C9fBv2K4x8b' }, '128k')
|
|
||||||
const u2 = await r2.promise
|
|
||||||
check('musicUrl songmid 兜底', u2.url === 'https://example.com/fake.mp3', u2.url)
|
|
||||||
check('取流路径/songmid', mock.seenUrls.some(u => u.includes('/v4s/url/tx/003C9fBv2K4x8b/128k')))
|
|
||||||
|
|
||||||
// 签名参数齐全
|
|
||||||
const lastUrl = mock.seenUrls.at(-1)
|
|
||||||
check('签名参数齐全', ['e=', 'to=', 'fp=', 's2=', 'v=5', 'si=', 's3='].every(k => lastUrl.includes(k)))
|
|
||||||
|
|
||||||
// rconfig 心跳(脚本加载时自动发出,走白名单放行)
|
|
||||||
check('启动 rconfig 心跳放行', mock.seenUrls.some(u => u.includes('/rconfig?')))
|
|
||||||
|
|
||||||
// ===== HTTP 默认阻止:白名单置空,脚本请求全部拦截 =====
|
|
||||||
const mockBlocked = makeMock()
|
|
||||||
const lb = await createIvmLxSource({ http: mockBlocked, allowedOrigins: [], initTimeout: 8000, log: false })
|
|
||||||
let blockedBehavior = 'init-failed'
|
|
||||||
try {
|
|
||||||
const rb = lb.apis.kw.getMusicUrl({ songmid: '28423286' }, '128k')
|
|
||||||
await rb.promise
|
|
||||||
blockedBehavior = 'unexpected-success'
|
|
||||||
} catch (e) {
|
|
||||||
blockedBehavior = `rejected: ${e.message}`
|
|
||||||
}
|
|
||||||
check('白名单置空后请求被拦截', !mockBlocked.seenUrls.length, `mock 被调用 ${mockBlocked.seenUrls.length} 次; ${blockedBehavior}`)
|
|
||||||
await lb.destroy()
|
|
||||||
|
|
||||||
// ===== 失败路径:code != 0 → 脚本内部错误码映射后 reject =====
|
|
||||||
let errSeen = ''
|
|
||||||
const mockFail = () => ({ promise: Promise.resolve({ statusCode: 200, statusMessage: 'OK', headers: {}, body: { code: 1, msg: '签名过期' } }), cancelHttp() {} })
|
|
||||||
const lx2 = await createIvmLxSource({ http: mockFail, log: false })
|
|
||||||
try {
|
|
||||||
await lx2.apis.kg.getMusicUrl({ hash: 'abc' }, 'flac').promise
|
|
||||||
} catch (e) { errSeen = e.message }
|
|
||||||
check('code!=0 reject(脚本错误映射)', !!errSeen && errSeen !== 'undefined', errSeen)
|
|
||||||
await lx2.destroy()
|
|
||||||
|
|
||||||
await lx.destroy()
|
|
||||||
console.log(failed ? `\n${failed} 项失败` : '\n全部通过')
|
|
||||||
process.exit(failed ? 1 : 0)
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
[
|
|
||||||
"ps://88.lxmusi",
|
|
||||||
"c.xn--fiqs8s",
|
|
||||||
"ci",
|
|
||||||
"sumxl",
|
|
||||||
"par",
|
|
||||||
"es",
|
|
||||||
"}]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"gm\",]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"yw\",]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"xt\",]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"gk\",]\"tib42calf\",\"calf\",\"k023\",\"k8",
|
|
||||||
"21\"[:\"wk\"{",
|
|
||||||
"sye",
|
|
||||||
"k",
|
|
||||||
"hs",
|
|
||||||
"up",
|
|
||||||
"l",
|
|
||||||
"acol",
|
|
||||||
"x",
|
|
||||||
"SEMAN_",
|
|
||||||
"TNEVE",
|
|
||||||
"tseuq",
|
|
||||||
"e",
|
|
||||||
"r",
|
|
||||||
"n",
|
|
||||||
"o",
|
|
||||||
"d",
|
|
||||||
"sli",
|
|
||||||
"vn",
|
|
||||||
"noisre",
|
|
||||||
"v",
|
|
||||||
"GIFNOC_TPIRCS_REVR",
|
|
||||||
"ES",
|
|
||||||
"litu",
|
|
||||||
"otpy",
|
|
||||||
"rc",
|
|
||||||
"m",
|
|
||||||
"5",
|
|
||||||
"ofnItpircStn",
|
|
||||||
"erruc",
|
|
||||||
"rev",
|
|
||||||
"sion",
|
|
||||||
"undefin",
|
|
||||||
"ed",
|
|
||||||
"u",
|
|
||||||
"ndefined",
|
|
||||||
"noitc",
|
|
||||||
"nuf",
|
|
||||||
"ro",
|
|
||||||
"tareti",
|
|
||||||
"gaTgnirt",
|
|
||||||
"Sot",
|
|
||||||
"getPrototype",
|
|
||||||
"Of",
|
|
||||||
"pytotorp",
|
|
||||||
"prot",
|
|
||||||
"et",
|
|
||||||
"aerc",
|
|
||||||
"epytoto",
|
|
||||||
"rp",
|
|
||||||
"construct",
|
|
||||||
"or",
|
|
||||||
"ytreporPeni",
|
|
||||||
"f",
|
|
||||||
"",
|
|
||||||
"value",
|
|
||||||
"enumerable",
|
|
||||||
"configurable",
|
|
||||||
"writable",
|
|
||||||
"rotcur",
|
|
||||||
"tsnoc",
|
|
||||||
"emaNy",
|
|
||||||
"alp",
|
|
||||||
"sid",
|
|
||||||
"oitcnuFrotareneG",
|
|
||||||
"noitcnuFro",
|
|
||||||
"tareneG",
|
|
||||||
"xt",
|
|
||||||
"wor",
|
|
||||||
"h",
|
|
||||||
"nrute",
|
|
||||||
"rotar",
|
|
||||||
"eneG",
|
|
||||||
"gn",
|
|
||||||
"irtSot",
|
|
||||||
"w",
|
|
||||||
"fOepytoto",
|
|
||||||
"fOe",
|
|
||||||
"p",
|
|
||||||
"ytotorPtes",
|
|
||||||
"rototype",
|
|
||||||
"eta",
|
|
||||||
"erc",
|
|
||||||
"appl",
|
|
||||||
"prototy",
|
|
||||||
"pe",
|
|
||||||
"eate",
|
|
||||||
"protot",
|
|
||||||
"epy",
|
|
||||||
"_invok",
|
|
||||||
"a",
|
|
||||||
"in",
|
|
||||||
"_i",
|
|
||||||
"nvoke",
|
|
||||||
"lac",
|
|
||||||
"app",
|
|
||||||
"ly",
|
|
||||||
"tacn",
|
|
||||||
"oc",
|
|
||||||
"/rc",
|
|
||||||
"onfig",
|
|
||||||
"T",
|
|
||||||
"G",
|
|
||||||
"method",
|
|
||||||
"nos",
|
|
||||||
"j/noitacilppa",
|
|
||||||
"Content-Type",
|
|
||||||
"co",
|
|
||||||
"-cisum-",
|
|
||||||
"lx",
|
|
||||||
"conca",
|
|
||||||
"/",
|
|
||||||
"t",
|
|
||||||
"acnoc",
|
|
||||||
"User-Agent",
|
|
||||||
"headers",
|
|
||||||
"follow_max",
|
|
||||||
"length",
|
|
||||||
"on",
|
|
||||||
"toStrin",
|
|
||||||
"g",
|
|
||||||
"ush",
|
|
||||||
"ni",
|
|
||||||
"oj",
|
|
||||||
"toStri",
|
|
||||||
"ahc",
|
|
||||||
"rCodeAt",
|
|
||||||
"ot",
|
|
||||||
"gnir",
|
|
||||||
"0",
|
|
||||||
"taep",
|
|
||||||
"er",
|
|
||||||
"ecolons",
|
|
||||||
"time",
|
|
||||||
"lps",
|
|
||||||
"it",
|
|
||||||
"am",
|
|
||||||
"tch",
|
|
||||||
"^https?:\\/\\/[^/]+(.*)$",
|
|
||||||
"sedul",
|
|
||||||
"c",
|
|
||||||
"?",
|
|
||||||
"fingerprin",
|
|
||||||
"current",
|
|
||||||
"ScriptInfo",
|
|
||||||
"tpir",
|
|
||||||
"cSwar",
|
|
||||||
"toLowerCa",
|
|
||||||
"se",
|
|
||||||
"Nd7Sk/2Mj9s/",
|
|
||||||
"Vdk=",
|
|
||||||
"ilp",
|
|
||||||
"s",
|
|
||||||
"sup",
|
|
||||||
"jo",
|
|
||||||
"gnirtS",
|
|
||||||
"dap",
|
|
||||||
"Start",
|
|
||||||
"esaCreppUo",
|
|
||||||
"til",
|
|
||||||
"ps",
|
|
||||||
"ioj",
|
|
||||||
"",
|
|
||||||
"tla",
|
|
||||||
"Sngis",
|
|
||||||
"|",
|
|
||||||
"tArah",
|
|
||||||
"charA",
|
|
||||||
"=",
|
|
||||||
"noloce",
|
|
||||||
"ot&",
|
|
||||||
"ti",
|
|
||||||
"&f",
|
|
||||||
"=p",
|
|
||||||
"=2s",
|
|
||||||
"&",
|
|
||||||
"&v",
|
|
||||||
"=is",
|
|
||||||
"si",
|
|
||||||
"gn3",
|
|
||||||
"done",
|
|
||||||
"valu",
|
|
||||||
"resolv",
|
|
||||||
"th",
|
|
||||||
"en",
|
|
||||||
"hcta",
|
|
||||||
"yd",
|
|
||||||
"ob",
|
|
||||||
"dat",
|
|
||||||
"tad",
|
|
||||||
"ause",
|
|
||||||
"Vnim",
|
|
||||||
"y",
|
|
||||||
"ilauQqh",
|
|
||||||
"hc",
|
|
||||||
"aErof",
|
|
||||||
"name",
|
|
||||||
"mus",
|
|
||||||
"type",
|
|
||||||
"ic",
|
|
||||||
"lrUci",
|
|
||||||
"sum",
|
|
||||||
"actions",
|
|
||||||
"qualitys",
|
|
||||||
"reque",
|
|
||||||
"st",
|
|
||||||
"i",
|
|
||||||
"detin",
|
|
||||||
"status",
|
|
||||||
"openDevTools",
|
|
||||||
"sources",
|
|
||||||
"no",
|
|
||||||
"itca",
|
|
||||||
"ec",
|
|
||||||
"ru",
|
|
||||||
"os",
|
|
||||||
"inf",
|
|
||||||
"lrUcis",
|
|
||||||
"um",
|
|
||||||
"fnIcisum",
|
|
||||||
"pyt",
|
|
||||||
"ppa",
|
|
||||||
"lo",
|
|
||||||
"cal",
|
|
||||||
"ha",
|
|
||||||
"sh",
|
|
||||||
"tac",
|
|
||||||
"/s4v/",
|
|
||||||
"noc",
|
|
||||||
"EG",
|
|
||||||
"application/jso",
|
|
||||||
"tacno",
|
|
||||||
"lx-music",
|
|
||||||
"-",
|
|
||||||
"source",
|
|
||||||
"songId",
|
|
||||||
"quality",
|
|
||||||
"sign3",
|
|
||||||
"sig",
|
|
||||||
"n3",
|
|
||||||
"ecr",
|
|
||||||
"3n",
|
|
||||||
"gnos",
|
|
||||||
"qu",
|
|
||||||
"ality",
|
|
||||||
"rever",
|
|
||||||
"=3",
|
|
||||||
"s&",
|
|
||||||
"3ngi",
|
|
||||||
"bo",
|
|
||||||
"cod",
|
|
||||||
"at",
|
|
||||||
"ad"
|
|
||||||
]
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// 端到端验证:经 src/apis/lx-source.js 懒加载封装真实取流一次
|
|
||||||
import lx from '../src/apis/lx-source.js'
|
|
||||||
|
|
||||||
const r = lx.kw.getMusicUrl({ songmid: '28423286' }, '320k')
|
|
||||||
const res = await Promise.race([
|
|
||||||
r.promise,
|
|
||||||
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout 25s')), 25000)),
|
|
||||||
])
|
|
||||||
console.log('[结果]', JSON.stringify(res).slice(0, 160))
|
|
||||||
process.exit(0)
|
|
||||||
Reference in new issue
Block a user