Compare commits
4
Commits
e1357dd412
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
800871ef6e | ||
|
|
76fb812a50 | ||
|
|
6b05fde64b | ||
|
|
cec63f864e |
No files matched your search
@@ -1,6 +1,7 @@
|
|||||||
# MusicServer 全量代码检查报告
|
# MusicServer 全量代码检查报告
|
||||||
|
|
||||||
**检查时间**:2026-10-03
|
**检查时间**:2026-10-03
|
||||||
|
**后续变更**:2026-10-21 汽水平台(qs)整体拆分至独立服务端 SodaServer(`gitea.4i.hk/hhhge/SodaServer`),文中 `sdk/qs`、`sdk/apis/qs.ts`、`/login/qs`、`/stream/qs` 及 README qs 行等表述已随拆分失效。
|
||||||
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
|
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
|
||||||
第四节 P2 卫生项已于 2026-10-03 清理完毕(注释死代码 630+ 行、裸 `console.log` 吞错 6 处、未使用符号 26 处清零、bili 状态校验去重);
|
第四节 P2 卫生项已于 2026-10-03 清理完毕(注释死代码 630+ 行、裸 `console.log` 吞错 6 处、未使用符号 26 处清零、bili 状态校验去重);
|
||||||
第八节线索逐条复核:qian 导出名、migu 异常消息、Quality `preview`、bili 重试重复均已过时(早前重写时已解决),kw/kg/tx/wy parse/playlist 仅有骨架相似、实质各异,评估后不抽公共层。
|
第八节线索逐条复核:qian 导出名、migu 异常消息、Quality `preview`、bili 重试重复均已过时(早前重写时已解决),kw/kg/tx/wy parse/playlist 仅有骨架相似、实质各异,评估后不抽公共层。
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(mg)、千千音乐(bd)、JOOX(joox)、哔哩哔哩(bili)、5sing(5sing);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
|
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、咪咕音乐(mg)、千千音乐(bd)、JOOX(joox)、哔哩哔哩(bili)、5sing(5sing);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
|
||||||
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
||||||
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
|
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
|
||||||
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
|
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
|
||||||
@@ -17,7 +17,6 @@
|
|||||||
| 酷狗音乐 | `kg` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/短信/账密/token |
|
| 酷狗音乐 | `kg` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/短信/账密/token |
|
||||||
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
|
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
|
||||||
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
|
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
|
||||||
| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
|
|
||||||
| 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
|
| 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
|
||||||
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
| 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
||||||
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
||||||
@@ -52,7 +51,7 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
|
| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
|
||||||
| `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
|
| `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
|
||||||
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
|
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
|
||||||
| `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`mg`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
|
| `GET /stream/:source/:id` | 加密流解密代理(`mg` 咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
|
||||||
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
|
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
|
||||||
| `GET /pic/:source/:id` | 获取封面 |
|
| `GET /pic/:source/:id` | 获取封面 |
|
||||||
| `GET /boards/:source` | 榜单列表 |
|
| `GET /boards/:source` | 榜单列表 |
|
||||||
@@ -79,12 +78,11 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
|
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
|
||||||
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
|
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
|
||||||
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
|
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
|
||||||
| `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(用**汽水音乐 App** 扫一扫——同官方 PC 客户端文案;抖音 App 扫可能不完成确认。登录后解锁完整曲目与无损取流) |
|
|
||||||
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
|
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
|
||||||
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
|
| `POST /login/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) |
|
||||||
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
|
| `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) |
|
||||||
| `POST /login/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
|
| `POST /login/bili/qr` → `POST /login/bili/qr/check` | 哔哩哔哩扫码登录(B站 App 扫一扫,登录后解锁大会员音质) |
|
||||||
| `DELETE /login/{wy,kg,tx,qs,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) |
|
| `DELETE /login/{wy,kg,tx,xm,me,lr,bili}` | 清除对应平台凭据(退出登录) |
|
||||||
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
|
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
|
||||||
| `GET /sources` | 平台列表(音质与能力) |
|
| `GET /sources` | 平台列表(音质与能力) |
|
||||||
| `GET /health` | 健康检查 |
|
| `GET /health` | 健康检查 |
|
||||||
@@ -98,7 +96,6 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
|
|
||||||
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
|
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
|
||||||
- **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
|
- **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
|
||||||
- **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目
|
|
||||||
- **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流
|
- **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流
|
||||||
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
|
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
|
||||||
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
|
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
|
||||||
@@ -129,22 +126,13 @@ X-Server-Key: <AUTH_KEY> # 统一验证头(全部路径)
|
|||||||
|
|
||||||
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。
|
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。
|
||||||
|
|
||||||
## 汽水 PC 签名(可选增强)
|
## 汽水音乐(已拆分)
|
||||||
|
|
||||||
汽水(`qs`)的 PC 通道(搜索优先走、取流降级走)依赖 `X-Helios`/`X-Medusa` 两个加密签名头,按优先级两种供给方式;都没有时 PC 通道自动关闭,只走安卓自签通道(匿名搜索/试听不受影响):
|
汽水音乐(`qs`)已拆分为独立服务端 **SodaServer**(`gitea.4i.hk/hhhge/SodaServer`,本地与 MusicServer 同级):API 路径兼容(`/search/qs`、`/url/qs`、`/stream/qs`、`/login/qs/*`),凭据文件格式同源可互迁;PC 签名继续复用 qs-signer 部署。
|
||||||
|
|
||||||
1. **签名服务器**:环境变量 `QS_SIGN_SERVER`(如 `http://127.0.0.1:8899`,可选 `QS_SIGN_TOKEN` 配 Bearer 鉴权)。契约兼容独立仓库 **qs-signer**(`gitea.4i.hk/hhhge/qs-signer`,本地与 MusicServer 同级)的两个服务——`server.mjs`(Wine + bdms.node)与 `mssdk-sign`(metasecml.dll 独立进程,见其 README),跨平台部署无需在本机安装官方客户端。
|
|
||||||
2. **固定值**:无签名服务器时,两个加密头直接用抓包固定值,写入 `data/qs-sign.json`(已 gitignore):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
|
|
||||||
```
|
|
||||||
|
|
||||||
`device_id`/`install_id` 必须与抓包时同一套(签名与设备绑定,会自动对齐 URL 参数);也可用环境变量 `QS_HELIOS`/`QS_MEDUSA`(配套 `QS_SIGN_DEVICE_ID`/`QS_SIGN_INSTALL_ID`)。固定值时效自负,被风控时搜索/取流自动回落安卓通道。
|
|
||||||
|
|
||||||
## 外部音源
|
## 外部音源
|
||||||
|
|
||||||
设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
|
设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
|
||||||
|
|
||||||
**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。
|
**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。
|
||||||
|
|
||||||
@@ -171,7 +159,7 @@ export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
|
|||||||
src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码)
|
src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码)
|
||||||
server.ts HTTP 服务器入口(pnpm start 实际启动文件)
|
server.ts HTTP 服务器入口(pnpm start 实际启动文件)
|
||||||
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
|
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
|
||||||
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 汽水扫码、
|
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、
|
||||||
bili 扫码、xm/me cookie 绑定、lr 账密)
|
bili 扫码、xm/me cookie 绑定、lr 账密)
|
||||||
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
|
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
|
||||||
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
|
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
|
||||||
@@ -185,18 +173,17 @@ src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源
|
|||||||
openapi/
|
openapi/
|
||||||
components.ts OpenAPI 共享 schema(与路由校验同源)
|
components.ts OpenAPI 共享 schema(与路由校验同源)
|
||||||
ui.ts ReDoc 页面模板
|
ui.ts ReDoc 页面模板
|
||||||
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;15 个平台)
|
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;14 个平台)
|
||||||
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
|
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
|
||||||
apis/ 各平台官方 URL 直连取流实现(15 平台)
|
apis/ 各平台官方 URL 直连取流实现(14 平台)
|
||||||
auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token/续期、tx.ts 扫码/cookie/续期)
|
auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token/续期、tx.ts 扫码/cookie/续期)
|
||||||
common/ SDK 基础设施
|
common/ SDK 基础设施
|
||||||
request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
|
request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
|
||||||
utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
|
utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
|
||||||
message.ts 请求错误消息
|
message.ts 请求错误消息
|
||||||
kg/ kw/ tx/ wy/ qs/ migu/ qian/ joox/ bili/ 5sing/ apple/ xm/ me/ qt/ lr/
|
kg/ kw/ tx/ wy/ migu/ qian/ joox/ bili/ 5sing/ apple/ xm/ me/ qt/ lr/
|
||||||
平台模块(搜索/歌词/榜单/歌单/专辑/链接解析等;qs 含字节系参数签名与音频解密 crypto/,
|
平台模块(搜索/歌词/榜单/歌单/专辑/链接解析等;migu 含 Z3D 流解密,bili 含扫码登录)
|
||||||
migu 含 Z3D 流解密,bili 含扫码登录)
|
apis/ kw.ts kg.ts tx.ts wy.ts migu.ts qian.ts joox.ts bili.ts 5sing.ts apple.ts …
|
||||||
apis/ kw.ts kg.ts tx.ts wy.ts qs.ts migu.ts qian.ts joox.ts bili.ts 5sing.ts apple.ts …
|
|
||||||
test/
|
test/
|
||||||
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
|
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
|
||||||
source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
|
source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
|
||||||
@@ -208,7 +195,6 @@ test/
|
|||||||
|
|
||||||
- **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
|
- **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
|
||||||
- **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
|
- **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
|
||||||
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
|
|
||||||
- **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
|
- **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
|
||||||
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
|
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
|
||||||
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
||||||
|
|||||||
+3
-5
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
||||||
|
|
||||||
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/
|
共 14 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `mg`(咪咕)/
|
||||||
`bd`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
|
`bd`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
|
||||||
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
|
|
||||||
### 歌曲 id 形态
|
### 歌曲 id 形态
|
||||||
|
|
||||||
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`mg` 的三元组、
|
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`xm`/`me` 的 id、`mg` 的三元组、
|
||||||
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
|
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
|
||||||
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
|
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
||||||
| GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
|
| GET | `/stream/:source/:id` | 加密流解密代理(`mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
|
||||||
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
|
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
|
||||||
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
|
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
|
||||||
|
|
||||||
@@ -119,8 +119,6 @@
|
|||||||
| POST | `/login/tx/qr` → `/login/tx/qr/check` | QQ 音乐扫码 |
|
| POST | `/login/tx/qr` → `/login/tx/qr/check` | QQ 音乐扫码 |
|
||||||
| POST | `/login/tx` | 绑定 QQ 音乐 cookie(可选 App 抓包字段供自动续期) |
|
| POST | `/login/tx` | 绑定 QQ 音乐 cookie(可选 App 抓包字段供自动续期) |
|
||||||
| DELETE | `/login/tx` | 退出 QQ 音乐 |
|
| DELETE | `/login/tx` | 退出 QQ 音乐 |
|
||||||
| POST | `/login/qs/qr` → `/login/qs/qr/check` | 汽水音乐扫码(用**汽水音乐 App** 扫一扫) |
|
|
||||||
| DELETE | `/login/qs` | 退出汽水音乐 |
|
|
||||||
| POST | `/login/xm` · `/login/me` | 绑定喜马拉雅 cookie / 猫耳FM token |
|
| POST | `/login/xm` · `/login/me` | 绑定喜马拉雅 cookie / 猫耳FM token |
|
||||||
| DELETE | `/login/xm` · `/login/me` | 退出对应平台 |
|
| DELETE | `/login/xm` · `/login/me` | 退出对应平台 |
|
||||||
| POST | `/login/lr` | 懒人听书账号登录(`account` + `password`) |
|
| POST | `/login/lr` | 懒人听书账号登录(`account` + `password`) |
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
"typecheck": "tsc",
|
"typecheck": "tsc",
|
||||||
"start": "node src/server.ts",
|
"start": "node src/server.ts",
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/smoke-test.ts"
|
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/bili-cdn-test.ts && node test/smoke-test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.3.0",
|
"@fastify/cors": "^11.3.0",
|
||||||
|
|||||||
+114
-37
@@ -4,9 +4,14 @@
|
|||||||
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
|
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
|
||||||
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
|
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
|
||||||
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
|
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
|
||||||
// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用),
|
//
|
||||||
// 失败时回退原始地址,保证客户端仍能自行跟跳。
|
// CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
|
||||||
// 参考 Referer/UA 必须携带,否则 CDN 可能 403。
|
// 迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
|
||||||
|
// 正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
|
||||||
|
// cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
|
||||||
|
// 因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
|
||||||
|
// 逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
|
||||||
|
// 参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
|
||||||
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
|
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
|
||||||
@@ -22,6 +27,11 @@ const QUALITY_LABEL: Record<number, string> = {
|
|||||||
30251: 'flac24bit',
|
30251: 'flac24bit',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
|
||||||
|
const PROBE_HOP_TIMEOUT = 8000
|
||||||
|
const PROBE_TOTAL_BUDGET = 20000
|
||||||
|
const PROBE_MAX_CANDIDATES = 3
|
||||||
|
|
||||||
let vipCache: { value: boolean, at: number } | null = null
|
let vipCache: { value: boolean, at: number } | null = null
|
||||||
|
|
||||||
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
|
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
|
||||||
@@ -47,34 +57,72 @@ export const isVipAccount = async(cookie: string): Promise<boolean> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickAudio = (data: any, type: string): { id: number, url: string, label: string } | null => {
|
/** CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / upos-*.akamaized.net,多地节点)
|
||||||
|
* 1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方) */
|
||||||
|
const hostRank = (url: string): number => {
|
||||||
|
let host = ''
|
||||||
|
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
|
||||||
|
if (host.endsWith('.akamaized.net')) return 0
|
||||||
|
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 节点地址收集:baseUrl + backupUrl(去重) */
|
||||||
|
const nodeUrls = (node: any): string[] => {
|
||||||
|
const backups = [
|
||||||
|
...(Array.isArray(node?.backupUrl) ? node.backupUrl : []),
|
||||||
|
...(Array.isArray(node?.backup_url) ? node.backup_url : []),
|
||||||
|
]
|
||||||
|
return [node?.baseUrl, ...backups].filter(Boolean).map(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先) */
|
||||||
|
const rankCandidates = (urls: string[]): string[] => {
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const uniq: string[] = []
|
||||||
|
for (const url of urls) {
|
||||||
|
const u = String(url || '')
|
||||||
|
if (!u || seen.has(u)) continue
|
||||||
|
seen.add(u)
|
||||||
|
uniq.push(u)
|
||||||
|
}
|
||||||
|
return uniq
|
||||||
|
.map((u, i) => ({ u, i, rank: hostRank(u) }))
|
||||||
|
.sort((a, b) => a.rank - b.rank || a.i - b.i)
|
||||||
|
.map(x => x.u)
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickAudio = (data: any, type: string): { id: number, label: string, urls: string[] } | null => {
|
||||||
const dash = data?.dash ?? {}
|
const dash = data?.dash ?? {}
|
||||||
const flac = dash?.flac?.audio
|
const flac = dash?.flac?.audio
|
||||||
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? { id: 30251, url: String(flac.baseUrl) } : null
|
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? flac : null
|
||||||
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
|
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
|
||||||
const standards: any[] = (dash?.audio ?? [])
|
const standards: any[] = (dash?.audio ?? [])
|
||||||
.filter((a: any) => a?.baseUrl)
|
.filter((a: any) => a?.baseUrl)
|
||||||
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
|
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
|
||||||
|
|
||||||
|
let node: any = null
|
||||||
|
let label = ''
|
||||||
if (type === '64k') {
|
if (type === '64k') {
|
||||||
const low = standards.find(a => Number(a.id) === 30216)
|
const low = standards.find(a => Number(a.id) === 30216)
|
||||||
if (low) return { id: 30216, url: String(low.baseUrl), label: QUALITY_LABEL[30216] }
|
if (low) { node = low; label = QUALITY_LABEL[30216] }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
|
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
|
||||||
if (hires) return { ...hires, label: QUALITY_LABEL[30251] }
|
if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] }
|
||||||
if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] }
|
if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] }
|
||||||
if (standards.length) {
|
if (!node && standards.length) {
|
||||||
const best = standards[0]
|
node = standards[0]
|
||||||
return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` }
|
label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}`
|
||||||
}
|
}
|
||||||
const durl = data?.durl?.[0]?.url
|
if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) }
|
||||||
if (durl) return { id: 0, url: String(durl), label: 'durl' }
|
|
||||||
|
const durlNode = data?.durl?.[0]
|
||||||
|
if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) }
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */
|
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */
|
||||||
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => {
|
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: string[] }> => {
|
||||||
const cookie = await getCookie()
|
const cookie = await getCookie()
|
||||||
const needFlac = type === 'flac' || type === 'flac24bit'
|
const needFlac = type === 'flac' || type === 'flac24bit'
|
||||||
const vip = await isVipAccount(cookie)
|
const vip = await isVipAccount(cookie)
|
||||||
@@ -84,36 +132,53 @@ export const getAudioUrl = async(bvid: string, cid: string | number, type = '128
|
|||||||
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`)
|
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`)
|
||||||
if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
|
if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
|
||||||
const picked = pickAudio(body.data, type)
|
const picked = pickAudio(body.data, type)
|
||||||
if (!picked) throw new Error('B站未返回可用音频流')
|
if (!picked || !picked.urls.length) throw new Error('B站未返回可用音频流')
|
||||||
return { type: picked.label, url: picked.url }
|
return { type: picked.label, urls: picked.urls }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */
|
/**
|
||||||
const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise<string> => {
|
* 探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开 body。
|
||||||
|
* HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
|
||||||
|
* 会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。
|
||||||
|
* 命中 2xx/206 返回该地址;4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
|
||||||
|
*/
|
||||||
|
const resolveUrl = async(url: string, tracker: { current: any, cancelled?: boolean }, deadline: number): Promise<string> => {
|
||||||
let current = String(url)
|
let current = String(url)
|
||||||
for (let hop = 0; hop < 3; hop++) {
|
for (let hop = 0; hop < 3; hop++) {
|
||||||
let response: any
|
const remaining = deadline - Date.now()
|
||||||
|
if (remaining <= 0) throw new Error('CDN 探测超时(总预算耗尽)')
|
||||||
|
const controller = new AbortController()
|
||||||
|
tracker.current = controller
|
||||||
|
const timer = setTimeout(() => controller.abort(new Error('CDN 探测超时')), Math.min(PROBE_HOP_TIMEOUT, remaining))
|
||||||
|
timer.unref?.()
|
||||||
|
let resp: Response
|
||||||
try {
|
try {
|
||||||
const request = httpFetch(current, {
|
resp = await fetch(current, {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' },
|
headers: { ...biliHeaders(), Range: 'bytes=0-0' },
|
||||||
timeout: 15000,
|
redirect: 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
})
|
})
|
||||||
if (tracker) tracker.current = request
|
} catch (err: any) {
|
||||||
response = await request.promise
|
if (tracker.cancelled) { err.canceled = true; throw err }
|
||||||
} catch {
|
throw err
|
||||||
return current
|
} finally {
|
||||||
|
clearTimeout(timer)
|
||||||
}
|
}
|
||||||
const statusCode = Number(response?.statusCode) || 0
|
// 拿到响应头即断开,不消费 body
|
||||||
const location = response?.headers?.location
|
await resp.body?.cancel().catch(() => {})
|
||||||
if (statusCode >= 300 && statusCode < 400 && location) {
|
const status = resp.status
|
||||||
|
const location = resp.headers.get('location')
|
||||||
|
if (status >= 300 && status < 400 && location) {
|
||||||
try {
|
try {
|
||||||
current = new URL(String(location), current).href
|
current = new URL(location, current).href
|
||||||
continue
|
continue
|
||||||
} catch {
|
} catch {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (status >= 200 && status < 300) return current
|
||||||
|
if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`)
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
return current
|
return current
|
||||||
@@ -123,13 +188,15 @@ const followRedirects = async(url: string, cookie: string, tracker?: { current:
|
|||||||
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
|
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
|
||||||
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
|
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
|
||||||
|
* CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
|
||||||
*/
|
*/
|
||||||
export const getMusicUrl = (songInfo: any, type: string) => {
|
export const getMusicUrl = (songInfo: any, type: string) => {
|
||||||
const tracker: { current: any } = { current: null }
|
const tracker: { current: any, cancelled?: boolean } = { current: null }
|
||||||
const requestObj: any = {
|
const requestObj: any = {
|
||||||
cancelHttp() {
|
cancelHttp() {
|
||||||
|
tracker.cancelled = true
|
||||||
try {
|
try {
|
||||||
tracker.current?.cancelHttp?.()
|
tracker.current?.abort?.(new Error('Canceled'))
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -147,9 +214,19 @@ export const getMusicUrl = (songInfo: any, type: string) => {
|
|||||||
}
|
}
|
||||||
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
|
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
|
||||||
const info = await getAudioUrl(bvid, realCid, type)
|
const info = await getAudioUrl(bvid, realCid, type)
|
||||||
const cookie = await getCookie()
|
|
||||||
const finalUrl = await followRedirects(info.url, cookie, tracker)
|
const deadline = Date.now() + PROBE_TOTAL_BUDGET
|
||||||
return { type: info.type, url: finalUrl }
|
for (const candidate of info.urls.slice(0, PROBE_MAX_CANDIDATES)) {
|
||||||
|
try {
|
||||||
|
const finalUrl = await resolveUrl(candidate, tracker, deadline)
|
||||||
|
return { type: info.type, url: finalUrl }
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.canceled || tracker.cancelled) throw new Error('canceled')
|
||||||
|
if (Date.now() >= deadline) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 探测全败(或预算耗尽):回退分级排序后的最优候选
|
||||||
|
return { type: info.type, url: info.urls[0] }
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return requestObj
|
return requestObj
|
||||||
|
|||||||
-255
@@ -1,255 +0,0 @@
|
|||||||
// 汽水音乐 URL 获取(直连)
|
|
||||||
// 两条取流通道:
|
|
||||||
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
|
|
||||||
// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr)
|
|
||||||
// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin)
|
|
||||||
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
|
|
||||||
// 登录请求失败自动降级匿名试听。
|
|
||||||
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
|
||||||
// promise resolve → { type, url }
|
|
||||||
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
|
||||||
import { createHash, randomBytes } from 'node:crypto'
|
|
||||||
import musicInfo from '../qs/musicInfo.ts'
|
|
||||||
import { getDevice } from '../qs/device.ts'
|
|
||||||
import { getCookie, getSessionId } from '../qs/passport.ts'
|
|
||||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
|
|
||||||
import { signRequest } from '../qs/crypto/sign.ts'
|
|
||||||
import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts'
|
|
||||||
import type { HttpResponse } from '../types.ts'
|
|
||||||
|
|
||||||
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
|
|
||||||
// 用户上传曲匿名即可整曲(实测 2026-10-02:DJ 版整曲 204.84s / 三档 68k・132k・260k)
|
|
||||||
export const supportQualitys = ['128k', '320k']
|
|
||||||
|
|
||||||
const TRACK_V2_URL = 'https://api.qishui.com/luna/pc/track_v2'
|
|
||||||
const STREAM_PREFIX = '/stream/qs/'
|
|
||||||
const STREAM_CACHE_TTL = 10 * 60_000
|
|
||||||
const STREAM_CACHE_MAX = 3
|
|
||||||
|
|
||||||
interface StreamEntry {
|
|
||||||
buffer: Buffer
|
|
||||||
contentType: string
|
|
||||||
bitrate: number
|
|
||||||
size: number
|
|
||||||
resolvedAt: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 已解密音频缓存(/url 解析一次,/stream 直接消费;LRU + TTL)
|
|
||||||
const streamCache = new Map<string, StreamEntry>()
|
|
||||||
const inflight = new Map<string, Promise<StreamEntry>>()
|
|
||||||
|
|
||||||
const cachePut = (trackId: string, entry: StreamEntry) => {
|
|
||||||
streamCache.delete(trackId) // 重新插入以刷新 LRU 顺序
|
|
||||||
streamCache.set(trackId, entry)
|
|
||||||
while (streamCache.size > STREAM_CACHE_MAX) {
|
|
||||||
streamCache.delete(streamCache.keys().next().value!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 已缓存的明文音频(/stream/qs/:id 用;缓存未命中返回 null,由路由触发重解析) */
|
|
||||||
export const getCachedStream = (trackId: string): StreamEntry | null => {
|
|
||||||
const entry = streamCache.get(trackId)
|
|
||||||
if (!entry) return null
|
|
||||||
if (Date.now() - entry.resolvedAt > STREAM_CACHE_TTL) {
|
|
||||||
streamCache.delete(trackId)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// 匿名通道:码率最高的未加密条目(带 PlayAuth = 加密,直接回 URL 不可播,跳过)
|
|
||||||
const pickPlainAudioUrl = (playInfoList: any): string | null => {
|
|
||||||
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl && !item.PlayAuth)
|
|
||||||
if (!entries.length) return null
|
|
||||||
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
|
|
||||||
return entries[0].MainPlayUrl
|
|
||||||
}
|
|
||||||
|
|
||||||
const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
|
|
||||||
const body = await musicInfo(trackId).promise
|
|
||||||
const playerInfoUrl = body?.track_player?.url_player_info
|
|
||||||
if (!playerInfoUrl) throw new Error('获取失败')
|
|
||||||
const { statusCode, body: playInfo } = await httpFetch(playerInfoUrl, { method: 'get', timeout: 15000 }).promise
|
|
||||||
const url = statusCode == 200 ? pickPlainAudioUrl(playInfo?.Result?.Data?.PlayInfoList) : null
|
|
||||||
if (!url) throw new Error('获取失败')
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频
|
|
||||||
const buildSignedUrl = (device: Awaited<ReturnType<typeof getDevice>>): string => {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
device_platform: 'android',
|
|
||||||
os: 'android',
|
|
||||||
ssmix: 'a',
|
|
||||||
cdid: device.cdid,
|
|
||||||
channel: device.channel,
|
|
||||||
aid: '386088',
|
|
||||||
app_name: 'luna',
|
|
||||||
version_code: '100198030',
|
|
||||||
version_name: '19.8.0',
|
|
||||||
manifest_version_code: '100198030',
|
|
||||||
update_version_code: '100198030',
|
|
||||||
resolution: '1080*1920',
|
|
||||||
dpi: '480',
|
|
||||||
device_type: device.device_type,
|
|
||||||
device_brand: device.device_brand,
|
|
||||||
language: 'zh',
|
|
||||||
os_api: '35',
|
|
||||||
os_version: device.os_version,
|
|
||||||
ac: 'wifi',
|
|
||||||
device_model: device.device_model,
|
|
||||||
device_id: device.device_id,
|
|
||||||
iid: device.install_id,
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
tz_offset: '28800',
|
|
||||||
sim_region: 'cn',
|
|
||||||
package: 'com.luna.music',
|
|
||||||
luna_apk_type: 'normal_apk',
|
|
||||||
output_device_type: 'Phone',
|
|
||||||
openudid: device.openudid,
|
|
||||||
_rticket: String(Date.now()),
|
|
||||||
})
|
|
||||||
return `${TRACK_V2_URL}?${params.toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickBestEntry = (playInfoList: any) => {
|
|
||||||
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
|
|
||||||
if (!entries.length) return null
|
|
||||||
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
|
|
||||||
return entries[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTrackPlayer = async (url: string, headers: Record<string, string>, requestBody: string) => {
|
|
||||||
const { statusCode, body } = (await httpFetch(url, {
|
|
||||||
method: 'post',
|
|
||||||
headers,
|
|
||||||
body: requestBody,
|
|
||||||
timeout: 15000,
|
|
||||||
}).promise) as HttpResponse
|
|
||||||
const trackPlayer = (body as any)?.track_player
|
|
||||||
if (statusCode != 200 || !trackPlayer?.url_player_info) {
|
|
||||||
const errMsg = (body as any)?.status_info?.status_msg || (body as any)?.message || ''
|
|
||||||
throw new Error(`track_v2 获取失败: HTTP ${statusCode} ${errMsg}`.trim())
|
|
||||||
}
|
|
||||||
return trackPlayer as { url_player_info: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
const TRACK_V2_BODY = (trackId: string) =>
|
|
||||||
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
|
||||||
|
|
||||||
// 通道 A(默认):安卓 App 参数 + X-Argus/X-Ladon 自签
|
|
||||||
const fetchTrackPlayerByApp = async (trackId: string) => {
|
|
||||||
const device = await getDevice()
|
|
||||||
const sessionId = getSessionId()
|
|
||||||
const url = buildSignedUrl(device)
|
|
||||||
const requestBody = TRACK_V2_BODY(trackId)
|
|
||||||
const headers = await signRequest(url, requestBody)
|
|
||||||
return fetchTrackPlayer(url, {
|
|
||||||
...headers,
|
|
||||||
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
|
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
'X-Luna-Background-Type': 'foreground',
|
|
||||||
'X-Luna-Is-Background-Req': '0',
|
|
||||||
'X-Luna-Is-Local-User': '1',
|
|
||||||
...(sessionId ? { Cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
|
||||||
}, requestBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 通道 B(降级):官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
|
|
||||||
const fetchTrackPlayerByPc = async (trackId: string) => {
|
|
||||||
const sessionId = getSessionId()
|
|
||||||
if (!mssdkAvailable()) throw new Error('PC 签名不可用')
|
|
||||||
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
|
|
||||||
const requestBody = TRACK_V2_BODY(trackId)
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'user-agent': PC_UA,
|
|
||||||
'x-luna-background-type': 'foreground',
|
|
||||||
'x-luna-is-background-req': '0',
|
|
||||||
'x-luna-is-local-user': '0',
|
|
||||||
'x-tt-trace-id': `00-${randomBytes(8).toString('hex')}-${randomBytes(8).toString('hex')}-01`,
|
|
||||||
'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(),
|
|
||||||
...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}),
|
|
||||||
}
|
|
||||||
const signed = await signQishuiRequest(url, headers, requestBody)
|
|
||||||
if (!signed) throw new Error('官方签名失败')
|
|
||||||
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveTrackPlayer = async (trackId: string) => {
|
|
||||||
try {
|
|
||||||
return await fetchTrackPlayerByApp(trackId)
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!mssdkAvailable()) throw err
|
|
||||||
console.log(`[qs/track_v2] 安卓自签通道失败(${err?.message ?? err}),降级官方 PC 签名`)
|
|
||||||
return fetchTrackPlayerByPc(trackId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
|
||||||
const trackPlayer = await resolveTrackPlayer(trackId)
|
|
||||||
|
|
||||||
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
|
|
||||||
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
|
|
||||||
if (!entry) throw new Error('无可用音频资源')
|
|
||||||
|
|
||||||
const audioRes = await httpFetch(entry.MainPlayUrl, { method: 'get', timeout: 30000 }).promise
|
|
||||||
const encrypted = audioRes.raw
|
|
||||||
if (!encrypted?.length) throw new Error('音频下载失败')
|
|
||||||
|
|
||||||
let buffer: Buffer = encrypted
|
|
||||||
let contentType = 'audio/mp4'
|
|
||||||
if (entry.PlayAuth) {
|
|
||||||
const hexKey = resolveAudioKey({ spade_a: entry.PlayAuth })
|
|
||||||
const decrypted = decryptAudioBuffer(new Uint8Array(encrypted), hexKey)
|
|
||||||
buffer = Buffer.from(decrypted.buffer)
|
|
||||||
contentType = decrypted.content_type
|
|
||||||
}
|
|
||||||
|
|
||||||
const streamEntry: StreamEntry = {
|
|
||||||
buffer,
|
|
||||||
contentType,
|
|
||||||
bitrate: entry.Bitrate || 0,
|
|
||||||
size: buffer.length,
|
|
||||||
resolvedAt: Date.now(),
|
|
||||||
}
|
|
||||||
cachePut(trackId, streamEntry)
|
|
||||||
return streamEntry
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 解析明文音频流(/stream/qs/:id 用;先查缓存,未命中重新解析) */
|
|
||||||
export const getStreamBuffer = async (trackId: string): Promise<StreamEntry> => {
|
|
||||||
const cached = getCachedStream(trackId)
|
|
||||||
if (cached) return cached
|
|
||||||
let work = inflight.get(trackId)
|
|
||||||
if (!work) {
|
|
||||||
work = resolveSignedStream(trackId).finally(() => inflight.delete(trackId))
|
|
||||||
inflight.set(trackId, work)
|
|
||||||
}
|
|
||||||
return work
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getMusicUrl = (songInfo, type) => {
|
|
||||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
|
||||||
const trackId = String(songInfo.songmid ?? '')
|
|
||||||
const sessionId = getSessionId()
|
|
||||||
if (sessionId) {
|
|
||||||
// 登录态:签名 track_v2 + 服务端解密,/url 阶段完成解析(快速失败),/stream 直接消费缓存
|
|
||||||
requestObj.promise = getStreamBuffer(trackId)
|
|
||||||
.then(entry => {
|
|
||||||
console.log(`[qs/url] ${trackId} 解密流就绪 bitrate=${entry.bitrate} size=${entry.size}`)
|
|
||||||
return { type, url: `${STREAM_PREFIX}${trackId}` }
|
|
||||||
})
|
|
||||||
.catch(err => {
|
|
||||||
console.log(`[qs/url] ${trackId} 登录态取流失败,降级匿名试听: ${err.message}`)
|
|
||||||
return anonymousPreviewUrl(trackId).then(url => ({ type, url }))
|
|
||||||
})
|
|
||||||
return requestObj
|
|
||||||
}
|
|
||||||
// 匿名:未加密试听直链
|
|
||||||
requestObj.promise = anonymousPreviewUrl(trackId).then(url => ({ type, url }))
|
|
||||||
return requestObj
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { getMusicUrl, getStreamBuffer, getCachedStream }
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} }
|
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_passport_device: {...} }
|
||||||
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
|
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
|
||||||
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
|
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
|
||||||
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
||||||
|
|||||||
@@ -113,6 +113,28 @@ export const cellphoneLogin = async({ phone, password, captcha, countrycode = '8
|
|||||||
return profile
|
return profile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动绑定 cookie(从已登录的 music.163.com 浏览器请求头复制,须含 MUSIC_U)。
|
||||||
|
* 绑定前用 account/get 实测校验,失效直接报 401,与 kg 绑定 token 同款语义。
|
||||||
|
*/
|
||||||
|
export const loginByCookie = async(cookieInput: string) => {
|
||||||
|
const cookie = String(cookieInput || '').trim()
|
||||||
|
const musicU = /MUSIC_U=([^;\s]+)/.exec(cookie)?.[1]
|
||||||
|
if (!musicU) throw Object.assign(new Error('cookie 需包含 MUSIC_U 字段'), { status: 422 })
|
||||||
|
const { body } = await requestWeapi('/api/w/nuser/account/get', {}, cookie)
|
||||||
|
if (!body?.account?.id) {
|
||||||
|
throw Object.assign(new Error('cookie 无效或已过期(MUSIC_U 校验未通过)'), { status: 401 })
|
||||||
|
}
|
||||||
|
const profile = {
|
||||||
|
userid: body.account.id,
|
||||||
|
nickname: body.profile?.nickname ?? null,
|
||||||
|
avatar: body.profile?.avatarUrl ?? null,
|
||||||
|
vipType: body.profile?.vipType ?? null,
|
||||||
|
}
|
||||||
|
setCredentials('wy', { cookie, musicU, ...profile })
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
/** 当前账号资料(未登录返回 null) */
|
/** 当前账号资料(未登录返回 null) */
|
||||||
export const status = async() => {
|
export const status = async() => {
|
||||||
if (!getWyCookie()) return null
|
if (!getWyCookie()) return null
|
||||||
|
|||||||
+3
-3
@@ -51,12 +51,12 @@ export const parseSong = async(link: string) => {
|
|||||||
page,
|
page,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 上游 Parse 会立即填充音频地址(失败忽略)
|
// 上游 Parse 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
|
||||||
if (cid) {
|
if (cid) {
|
||||||
try {
|
try {
|
||||||
const audio = await getAudioUrl(song.bvid, cid)
|
const audio = await getAudioUrl(song.bvid, cid)
|
||||||
if (audio?.url) {
|
if (audio?.urls?.[0]) {
|
||||||
song.url = audio.url
|
song.url = audio.urls[0]
|
||||||
song.type = audio.type
|
song.type = audio.type
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import kw from './kw/index.ts'
|
|||||||
import kg from './kg/index.ts'
|
import kg from './kg/index.ts'
|
||||||
import tx from './tx/index.ts'
|
import tx from './tx/index.ts'
|
||||||
import wy from './wy/index.ts'
|
import wy from './wy/index.ts'
|
||||||
import qs from './qs/index.ts'
|
|
||||||
import xm from './xm/index.ts'
|
import xm from './xm/index.ts'
|
||||||
import me from './me/index.ts'
|
import me from './me/index.ts'
|
||||||
import qt from './qt/index.ts'
|
import qt from './qt/index.ts'
|
||||||
@@ -17,7 +16,6 @@ import { supportQualitys as kwQualitys } from './apis/kw.ts'
|
|||||||
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
||||||
import { supportQualitys as txQualitys } from './apis/tx.ts'
|
import { supportQualitys as txQualitys } from './apis/tx.ts'
|
||||||
import { supportQualitys as wyQualitys } from './apis/wy.ts'
|
import { supportQualitys as wyQualitys } from './apis/wy.ts'
|
||||||
import { supportQualitys as qsQualitys } from './apis/qs.ts'
|
|
||||||
import { supportQualitys as xmQualitys } from './apis/xm.ts'
|
import { supportQualitys as xmQualitys } from './apis/xm.ts'
|
||||||
import { supportQualitys as meQualitys } from './apis/me.ts'
|
import { supportQualitys as meQualitys } from './apis/me.ts'
|
||||||
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
||||||
@@ -48,7 +46,6 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
|
|||||||
{ id: 'kg', name: '酷狗音乐', module: kg },
|
{ id: 'kg', name: '酷狗音乐', module: kg },
|
||||||
{ id: 'tx', name: 'QQ音乐', module: tx },
|
{ id: 'tx', name: 'QQ音乐', module: tx },
|
||||||
{ id: 'wy', name: '网易音乐', module: wy },
|
{ id: 'wy', name: '网易音乐', module: wy },
|
||||||
{ id: 'qs', name: '汽水音乐', module: qs },
|
|
||||||
{ id: 'xm', name: '喜马拉雅', module: xm, noAggregate: true },
|
{ id: 'xm', name: '喜马拉雅', module: xm, noAggregate: true },
|
||||||
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
||||||
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
||||||
@@ -76,7 +73,6 @@ export const supportQuality: Record<string, string[]> = {
|
|||||||
kg: kgQualitys,
|
kg: kgQualitys,
|
||||||
tx: txQualitys,
|
tx: txQualitys,
|
||||||
wy: wyQualitys,
|
wy: wyQualitys,
|
||||||
qs: qsQualitys,
|
|
||||||
xm: xmQualitys,
|
xm: xmQualitys,
|
||||||
me: meQualitys,
|
me: meQualitys,
|
||||||
qt: qtQualitys,
|
qt: qtQualitys,
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { b64encode, getCrypto, getSubtle, hash, pkcs7Pad } from "./index.ts";
|
|
||||||
import { ProtobufWriter } from "./protobuf.ts";
|
|
||||||
import { simonEncrypt } from "./simon.ts";
|
|
||||||
import { sm3 } from "./sm3.ts";
|
|
||||||
import type { UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
|
|
||||||
const LOW_RAND = new Uint8Array([0xf2, 0x81]);
|
|
||||||
const HIGH_RAND = new Uint8Array([0x61, 0x6f]); // 'a', 'o'
|
|
||||||
const XOR_PREFIX = new Uint8Array([0xf2, 0xf7, 0xfc, 0xff, 0xf2, 0xf7, 0xfc, 0xff]);
|
|
||||||
|
|
||||||
function sm3Prefix6(data: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
||||||
return sm3(data).slice(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeStub(xssStub: string): Uint8Array<ArrayBuffer> {
|
|
||||||
const bytes = new Uint8Array(16);
|
|
||||||
if (xssStub.length >= 32) {
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
bytes[i] = parseInt(xssStub.slice(i * 2, i * 2 + 2), 16) & 0xff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return bytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateConstant(code: string): number {
|
|
||||||
const parts = [...code.replace(/\./g, "").padStart(6, "0")].map(Number);
|
|
||||||
const weights = [20480, 2048, 20971520, 2097152, 1342177280, 134217728];
|
|
||||||
return parts.reduce((sum, part, i) => sum + part * weights[i]!, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildProtobuf(
|
|
||||||
query: string,
|
|
||||||
xssStub: string,
|
|
||||||
timestamp: number,
|
|
||||||
config: UnidbgConfig,
|
|
||||||
): Uint8Array<ArrayBuffer> {
|
|
||||||
const params = new URLSearchParams(query);
|
|
||||||
const deviceId = params.get("device_id") ?? "";
|
|
||||||
const versionName = params.get("version_name") ?? "";
|
|
||||||
const deviceType = params.get("device_type") ?? "";
|
|
||||||
const osVersion = params.get("os_version") ?? "";
|
|
||||||
const channel = params.get("channel") ?? "";
|
|
||||||
|
|
||||||
// 空 stub / 空 query 时都对 16 字节全零求 SM3
|
|
||||||
const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub));
|
|
||||||
const queryHash = sm3Prefix6(
|
|
||||||
query === "" ? new Uint8Array(16) : new TextEncoder().encode(query),
|
|
||||||
);
|
|
||||||
|
|
||||||
const rand = getCrypto().getRandomValues(new Uint32Array(1))[0]! % 0x7fffffff;
|
|
||||||
|
|
||||||
return new ProtobufWriter()
|
|
||||||
.varint(1, 0x20200929 * 2) // magic
|
|
||||||
.varint(2, 2) // version
|
|
||||||
.varint(3, rand)
|
|
||||||
.string(4, config.aid)
|
|
||||||
.string(5, deviceId)
|
|
||||||
.string(6, config.licenseId)
|
|
||||||
.string(7, versionName)
|
|
||||||
.string(8, config.sdkVersion)
|
|
||||||
.varint(9, config.sdkVersionInt)
|
|
||||||
.bytes(10, new Uint8Array(8)) // envcode
|
|
||||||
.varint(11, 0) // platform
|
|
||||||
.varint(12, timestamp * 2) // createTime
|
|
||||||
.bytes(13, bodyHash)
|
|
||||||
.bytes(14, queryHash)
|
|
||||||
.message(15, (sub) => {
|
|
||||||
sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860);
|
|
||||||
})
|
|
||||||
.string(16, config.secDeviceId ?? "") // sec_device_id
|
|
||||||
.string(20, "none") // pskVersion
|
|
||||||
.varint(21, config.callType)
|
|
||||||
.message(23, (sub) => {
|
|
||||||
sub.string(1, deviceType).string(2, osVersion).string(3, channel).varint(4, calculateConstant(osVersion));
|
|
||||||
})
|
|
||||||
.varint(25, 2)
|
|
||||||
.toBytes();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function getArgus(
|
|
||||||
query: string,
|
|
||||||
xssStub: string,
|
|
||||||
timestamp: number,
|
|
||||||
config: UnidbgConfig,
|
|
||||||
): Promise<string> {
|
|
||||||
const { signKey } = config;
|
|
||||||
if (signKey.length !== 32) {
|
|
||||||
throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16);
|
|
||||||
|
|
||||||
const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length);
|
|
||||||
sm3Input.set(signKey, 0);
|
|
||||||
sm3Input.set(LOW_RAND, signKey.length);
|
|
||||||
sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length);
|
|
||||||
sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length);
|
|
||||||
|
|
||||||
const sm3Output = sm3(sm3Input);
|
|
||||||
const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength);
|
|
||||||
const simonKey: [bigint, bigint, bigint, bigint] = [
|
|
||||||
keyView.getBigUint64(0, true),
|
|
||||||
keyView.getBigUint64(8, true),
|
|
||||||
keyView.getBigUint64(16, true),
|
|
||||||
keyView.getBigUint64(24, true),
|
|
||||||
];
|
|
||||||
|
|
||||||
const encrypted = new Uint8Array(protobuf.length);
|
|
||||||
const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength);
|
|
||||||
const encView = new DataView(encrypted.buffer);
|
|
||||||
for (let offset = 0; offset < protobuf.length; offset += 16) {
|
|
||||||
const [low, high] = simonEncrypt(
|
|
||||||
[pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)],
|
|
||||||
simonKey,
|
|
||||||
);
|
|
||||||
encView.setBigUint64(offset, low, true);
|
|
||||||
encView.setBigUint64(offset + 8, high, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = new Uint8Array(XOR_PREFIX.length + encrypted.length);
|
|
||||||
data.set(XOR_PREFIX);
|
|
||||||
data.set(encrypted, XOR_PREFIX.length);
|
|
||||||
for (let i = XOR_PREFIX.length; i < data.length; i++) {
|
|
||||||
data[i]! ^= data[i % 8]!;
|
|
||||||
}
|
|
||||||
data.reverse();
|
|
||||||
|
|
||||||
const header = new Uint8Array([0xa6, 0x6e, 0xad, 0x9f, 0x77, 0x01, 0xd0, 0x0c, 0x18]);
|
|
||||||
const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length);
|
|
||||||
plaintext.set(header);
|
|
||||||
plaintext.set(data, header.length);
|
|
||||||
plaintext.set(HIGH_RAND, header.length + data.length);
|
|
||||||
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const aesKey = await subtle.importKey(
|
|
||||||
"raw",
|
|
||||||
await hash.md5bytes(signKey.slice(0, 16)),
|
|
||||||
{ name: "AES-CBC" },
|
|
||||||
false,
|
|
||||||
["encrypt"],
|
|
||||||
);
|
|
||||||
const iv = await hash.md5bytes(signKey.slice(16));
|
|
||||||
const ciphertext = new Uint8Array(
|
|
||||||
await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext),
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = new Uint8Array(LOW_RAND.length + ciphertext.length);
|
|
||||||
result.set(LOW_RAND);
|
|
||||||
result.set(ciphertext, LOW_RAND.length);
|
|
||||||
return b64encode(result.buffer);
|
|
||||||
}
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
// CENC 音频解密(AES-CTR 逐样本)—— 移植自 guowenye/qishui-api src/audioDecryptor.js
|
|
||||||
// spade_a/PlayAuth → 32 位 hex key;密音频(m4a CENC / flac)→ 明文音频 Buffer
|
|
||||||
import { createDecipheriv } from 'node:crypto'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const encryptedBoxTypes = new Set(['senc', 'saio', 'saiz', 'sinf', 'schi', 'tenc', 'schm', 'frma'])
|
|
||||||
const containerBoxTypes = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd'])
|
|
||||||
const maxSampleCount = 200000
|
|
||||||
const maxChunkCount = 200000
|
|
||||||
const maxStscEntryCount = 20000
|
|
||||||
|
|
||||||
function decodeBase36(codePoint) {
|
|
||||||
if (codePoint >= 48 && codePoint <= 57) return codePoint - 48
|
|
||||||
if (codePoint >= 97 && codePoint <= 122) return codePoint - 97 + 10
|
|
||||||
return 0xFF
|
|
||||||
}
|
|
||||||
|
|
||||||
function bitCount(number) {
|
|
||||||
let value = number - ((number >> 1) & 0x55555555)
|
|
||||||
value = (value & 0x33333333) + ((value >> 2) & 0x33333333)
|
|
||||||
return (((value + (value >> 4)) & 0xF0F0F0F) * 0x1010101) >> 24
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptSpadeInner(spadeKey) {
|
|
||||||
const result = new Uint8Array(spadeKey)
|
|
||||||
const buffer = new Uint8Array(2 + spadeKey.length)
|
|
||||||
buffer.set([0xFA, 0x55], 0)
|
|
||||||
buffer.set(spadeKey, 2)
|
|
||||||
|
|
||||||
for (let index = 0; index < result.length; index += 1) {
|
|
||||||
let value = (spadeKey[index] ^ buffer[index]) - bitCount(index) - 21
|
|
||||||
while (value < 0) value += 0xFF
|
|
||||||
result[index] = value
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptSpade(spadeKeyBytes) {
|
|
||||||
const spadeKeyLength = spadeKeyBytes.length
|
|
||||||
if (spadeKeyLength < 3) return ''
|
|
||||||
const paddingLength = (spadeKeyBytes[0] ^ spadeKeyBytes[1] ^ spadeKeyBytes[2]) - 48
|
|
||||||
if (spadeKeyLength < paddingLength + 2) return ''
|
|
||||||
const innerInput = spadeKeyBytes.slice(1, spadeKeyLength - paddingLength)
|
|
||||||
const decodedBuffer = decryptSpadeInner(innerInput)
|
|
||||||
if (decodedBuffer.length === 0) return ''
|
|
||||||
const skipBytes = decodeBase36(decodedBuffer[0])
|
|
||||||
const decodedMessageLength = spadeKeyLength - paddingLength - 2
|
|
||||||
const endIndex = 1 + decodedMessageLength - skipBytes
|
|
||||||
if (endIndex > decodedBuffer.length) return ''
|
|
||||||
return Buffer.from(decodedBuffer.slice(1, endIndex)).toString('utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptSpadeA(spadeA) {
|
|
||||||
try {
|
|
||||||
return decryptSpade(new Uint8Array(Buffer.from(String(spadeA || '').trim(), 'base64')))
|
|
||||||
} catch (_) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isHexKey(value) {
|
|
||||||
return typeof value === 'string' && /^[a-f0-9]{32}$/i.test(value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAudioKey(source: any = {}) {
|
|
||||||
const rawHexKey = source.hex_key || source.key
|
|
||||||
if (isHexKey(rawHexKey)) return rawHexKey.trim().toLowerCase()
|
|
||||||
|
|
||||||
const rawSpadeA = source.spade_a || source.spadeA
|
|
||||||
if (!rawSpadeA) throw new Error('spade_a 或 hex_key 不能为空')
|
|
||||||
const hexKey = decryptSpadeA(rawSpadeA).trim()
|
|
||||||
if (!isHexKey(hexKey)) throw new Error('spade_a 或 hex_key 无效')
|
|
||||||
return hexKey.toLowerCase()
|
|
||||||
}
|
|
||||||
|
|
||||||
function hexToBytes(hex) {
|
|
||||||
if (!isHexKey(hex)) throw new Error('hex_key 必须是 32 位十六进制 AES-128 密钥')
|
|
||||||
return Buffer.from(hex, 'hex')
|
|
||||||
}
|
|
||||||
|
|
||||||
function asUint8Array(input) {
|
|
||||||
if (input instanceof Uint8Array) return input
|
|
||||||
if (Buffer.isBuffer(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
|
||||||
if (input instanceof ArrayBuffer) return new Uint8Array(input)
|
|
||||||
throw new Error('音频数据必须是 Buffer、Uint8Array 或 ArrayBuffer')
|
|
||||||
}
|
|
||||||
|
|
||||||
function concatUint8Arrays(arrays: any[]) {
|
|
||||||
const totalLength = arrays.reduce((sum, array) => sum + array.length, 0)
|
|
||||||
const result = new Uint8Array(totalLength)
|
|
||||||
let offset = 0
|
|
||||||
for (const array of arrays) {
|
|
||||||
result.set(array, offset)
|
|
||||||
offset += array.length
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function readUint32BE(fileData, offset) {
|
|
||||||
if (!Number.isInteger(offset) || offset < 0 || offset + 4 > fileData.length) {
|
|
||||||
throw new Error('音频容器结构无效')
|
|
||||||
}
|
|
||||||
return new DataView(fileData.buffer, fileData.byteOffset, fileData.byteLength).getUint32(offset, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeUint32BE(value) {
|
|
||||||
const output = new Uint8Array(4)
|
|
||||||
new DataView(output.buffer).setUint32(0, value, false)
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
function readAscii(fileData, start, end) {
|
|
||||||
return Buffer.from(fileData.subarray(start, end)).toString('ascii')
|
|
||||||
}
|
|
||||||
|
|
||||||
class MP4Box {
|
|
||||||
offset: number
|
|
||||||
size: number
|
|
||||||
type: string
|
|
||||||
data: Uint8Array
|
|
||||||
|
|
||||||
constructor(fileData: Uint8Array, offset: number) {
|
|
||||||
this.offset = offset
|
|
||||||
this.size = readUint32BE(fileData, offset)
|
|
||||||
this.type = readAscii(fileData, offset + 4, offset + 8)
|
|
||||||
this.data = fileData.subarray(offset + 8, offset + this.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
static findBox(fileData: Uint8Array, boxType: string, offset = 0, end: number | null = null) {
|
|
||||||
const searchEnd = end === null ? fileData.length : end
|
|
||||||
let position = offset
|
|
||||||
while (position < searchEnd) {
|
|
||||||
if (position + 8 > searchEnd) break
|
|
||||||
const boxSize = readUint32BE(fileData, position)
|
|
||||||
if (boxSize === 0 || boxSize > searchEnd - position || boxSize < 8) break
|
|
||||||
const currentType = readAscii(fileData, position + 4, position + 8)
|
|
||||||
if (currentType === boxType) return new MP4Box(fileData, position)
|
|
||||||
position += boxSize
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireBox(box, name) {
|
|
||||||
if (!box) throw new Error(`音频容器缺少 ${name} box`)
|
|
||||||
return box
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStsz(stszData) {
|
|
||||||
const sampleSize = readUint32BE(stszData, 4)
|
|
||||||
const sampleCount = readUint32BE(stszData, 8)
|
|
||||||
if (sampleCount > maxSampleCount) throw new Error('音频样本数量超过限制')
|
|
||||||
if (sampleSize !== 0) return new Array(sampleCount).fill(sampleSize)
|
|
||||||
const requiredLength = 12 + sampleCount * 4
|
|
||||||
if (requiredLength > stszData.length) throw new Error('stsz box 样本大小数据不完整')
|
|
||||||
const sampleSizes: number[] = []
|
|
||||||
for (let index = 0; index < sampleCount; index += 1) {
|
|
||||||
sampleSizes.push(readUint32BE(stszData, 12 + index * 4))
|
|
||||||
}
|
|
||||||
return sampleSizes
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseStsc(stscData) {
|
|
||||||
const entryCount = readUint32BE(stscData, 4)
|
|
||||||
if (entryCount > maxStscEntryCount) throw new Error('stsc entry 数量超过限制')
|
|
||||||
const requiredLength = 8 + entryCount * 12
|
|
||||||
if (requiredLength > stscData.length) throw new Error('stsc box 数据不完整')
|
|
||||||
const entries: any[] = []
|
|
||||||
for (let index = 0; index < entryCount; index += 1) {
|
|
||||||
const baseOffset = 8 + index * 12
|
|
||||||
entries.push({
|
|
||||||
firstChunk: readUint32BE(stscData, baseOffset),
|
|
||||||
samplesPerChunk: readUint32BE(stscData, baseOffset + 4),
|
|
||||||
id: readUint32BE(stscData, baseOffset + 8),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSenc(sencData) {
|
|
||||||
const sampleCount = readUint32BE(sencData, 4)
|
|
||||||
if (sampleCount > maxSampleCount) throw new Error('senc 样本数量超过限制')
|
|
||||||
const requiredLength = 8 + sampleCount * 8
|
|
||||||
if (requiredLength > sencData.length) throw new Error('senc box IV 数据不完整')
|
|
||||||
const ivs: Uint8Array[] = []
|
|
||||||
let position = 8
|
|
||||||
for (let index = 0; index < sampleCount; index += 1) {
|
|
||||||
const iv = new Uint8Array(16)
|
|
||||||
iv.set(sencData.subarray(position, position + 8))
|
|
||||||
ivs.push(iv)
|
|
||||||
position += 8
|
|
||||||
}
|
|
||||||
return ivs
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculateChunkOffsets(sampleSizes, stscEntries, chunkCount, baseOffset) {
|
|
||||||
const offsets: number[] = []
|
|
||||||
let currentOffset = baseOffset
|
|
||||||
let sampleIndex = 0
|
|
||||||
for (let chunkIndex = 1; chunkIndex <= chunkCount; chunkIndex += 1) {
|
|
||||||
offsets.push(currentOffset)
|
|
||||||
let samplesPerChunk = 0
|
|
||||||
for (let entryIndex = 0; entryIndex < stscEntries.length; entryIndex += 1) {
|
|
||||||
const currentEntry = stscEntries[entryIndex]
|
|
||||||
const nextEntry = stscEntries[entryIndex + 1]
|
|
||||||
if (chunkIndex >= currentEntry.firstChunk && (!nextEntry || chunkIndex < nextEntry.firstChunk)) {
|
|
||||||
samplesPerChunk = currentEntry.samplesPerChunk
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let sampleOffset = 0; sampleOffset < samplesPerChunk; sampleOffset += 1) {
|
|
||||||
if (sampleIndex < sampleSizes.length) currentOffset += sampleSizes[sampleIndex]
|
|
||||||
sampleIndex += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return offsets
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStco(stcoData, offsets) {
|
|
||||||
const chunkCount = readUint32BE(stcoData, 4)
|
|
||||||
if (offsets.length < chunkCount) throw new Error('stco 偏移数量不足')
|
|
||||||
const header = stcoData.subarray(0, 8)
|
|
||||||
const body = new Uint8Array(chunkCount * 4)
|
|
||||||
const view = new DataView(body.buffer)
|
|
||||||
for (let index = 0; index < chunkCount; index += 1) view.setUint32(index * 4, offsets[index], false)
|
|
||||||
return concatUint8Arrays([header, body])
|
|
||||||
}
|
|
||||||
|
|
||||||
function processBoxTree(fileData, offset, size, newMdatOffset, context) {
|
|
||||||
const parts: Uint8Array[] = []
|
|
||||||
let position = offset + 8
|
|
||||||
const end = offset + size
|
|
||||||
|
|
||||||
while (position < end) {
|
|
||||||
if (position + 8 > end) {
|
|
||||||
parts.push(fileData.subarray(position, end))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const boxSize = readUint32BE(fileData, position)
|
|
||||||
if (boxSize < 8 || boxSize > end - position) {
|
|
||||||
parts.push(fileData.subarray(position, end))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const type = readAscii(fileData, position + 4, position + 8)
|
|
||||||
|
|
||||||
if (encryptedBoxTypes.has(type)) {
|
|
||||||
position += boxSize
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'enca') {
|
|
||||||
const inner = processBoxTree(fileData, position, boxSize, newMdatOffset, context)
|
|
||||||
parts.push(writeUint32BE(inner.length + 8), Buffer.from('mp4a'), inner)
|
|
||||||
position += boxSize
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type === 'stco') {
|
|
||||||
const offsets = calculateChunkOffsets(context.sampleSizes, context.stscEntries, context.chunkCount, newMdatOffset)
|
|
||||||
const updatedBody = updateStco(fileData.subarray(position + 8, position + boxSize), offsets)
|
|
||||||
parts.push(writeUint32BE(updatedBody.length + 8), Buffer.from('stco'), updatedBody)
|
|
||||||
position += boxSize
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (containerBoxTypes.has(type)) {
|
|
||||||
const inner = processBoxTree(fileData, position, boxSize, newMdatOffset, context)
|
|
||||||
parts.push(writeUint32BE(inner.length + 8), Buffer.from(type), inner)
|
|
||||||
position += boxSize
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push(fileData.subarray(position, position + boxSize))
|
|
||||||
position += boxSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return concatUint8Arrays(parts)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scanForFlacMetadata(stsdData) {
|
|
||||||
const marker = Buffer.from('dfLa')
|
|
||||||
for (let index = 4; index < stsdData.length - 4; index += 1) {
|
|
||||||
if (
|
|
||||||
stsdData[index] === marker[0]
|
|
||||||
&& stsdData[index + 1] === marker[1]
|
|
||||||
&& stsdData[index + 2] === marker[2]
|
|
||||||
&& stsdData[index + 3] === marker[3]
|
|
||||||
) {
|
|
||||||
const boxSize = readUint32BE(stsdData, index - 4)
|
|
||||||
if (boxSize >= 8 && index - 4 + boxSize <= stsdData.length) {
|
|
||||||
return stsdData.subarray(index + 4, index - 4 + boxSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptCtr(encryptedSample, keyBytes, iv) {
|
|
||||||
const decipher = createDecipheriv(`aes-${keyBytes.length * 8}-ctr`, keyBytes, Buffer.from(iv))
|
|
||||||
decipher.setAutoPadding(false)
|
|
||||||
return Buffer.concat([decipher.update(Buffer.from(encryptedSample)), decipher.final()])
|
|
||||||
}
|
|
||||||
|
|
||||||
function decryptAudioBuffer(input, hexKey) {
|
|
||||||
const fileData = asUint8Array(input)
|
|
||||||
const keyBytes = hexToBytes(hexKey)
|
|
||||||
|
|
||||||
const ftyp = MP4Box.findBox(fileData, 'ftyp')
|
|
||||||
const moov = requireBox(MP4Box.findBox(fileData, 'moov'), 'moov')
|
|
||||||
const trak = requireBox(MP4Box.findBox(fileData, 'trak', moov.offset + 8, moov.offset + moov.size), 'trak')
|
|
||||||
const mdia = requireBox(MP4Box.findBox(fileData, 'mdia', trak.offset + 8, trak.offset + trak.size), 'mdia')
|
|
||||||
const minf = requireBox(MP4Box.findBox(fileData, 'minf', mdia.offset + 8, mdia.offset + mdia.size), 'minf')
|
|
||||||
const stbl = requireBox(MP4Box.findBox(fileData, 'stbl', minf.offset + 8, minf.offset + minf.size), 'stbl')
|
|
||||||
const stsd = requireBox(MP4Box.findBox(fileData, 'stsd', stbl.offset + 8, stbl.offset + stbl.size), 'stsd')
|
|
||||||
const stsz = requireBox(MP4Box.findBox(fileData, 'stsz', stbl.offset + 8, stbl.offset + stbl.size), 'stsz')
|
|
||||||
const stsc = requireBox(MP4Box.findBox(fileData, 'stsc', stbl.offset + 8, stbl.offset + stbl.size), 'stsc')
|
|
||||||
const stco = requireBox(MP4Box.findBox(fileData, 'stco', stbl.offset + 8, stbl.offset + stbl.size), 'stco')
|
|
||||||
const mdat = requireBox(MP4Box.findBox(fileData, 'mdat'), 'mdat')
|
|
||||||
|
|
||||||
let senc = MP4Box.findBox(fileData, 'senc', stbl.offset + 8, stbl.offset + stbl.size)
|
|
||||||
if (!senc) senc = MP4Box.findBox(fileData, 'senc', moov.offset + 8, moov.offset + moov.size)
|
|
||||||
requireBox(senc, 'senc')
|
|
||||||
|
|
||||||
const sampleSizes = parseStsz(stsz.data)
|
|
||||||
const stscEntries = parseStsc(stsc.data)
|
|
||||||
const chunkCount = readUint32BE(stco.data, 4)
|
|
||||||
if (chunkCount > maxChunkCount) throw new Error('音频 chunk 数量超过限制')
|
|
||||||
if (8 + chunkCount * 4 > stco.data.length) throw new Error('stco box 数据不完整')
|
|
||||||
const ivs = parseSenc(requireBox(senc, 'senc').data)
|
|
||||||
if (ivs.length < sampleSizes.length) throw new Error('senc IV 数量少于样本数量')
|
|
||||||
const encryptedPayloadSize = sampleSizes.reduce((sum, sampleSize) => sum + sampleSize, 0)
|
|
||||||
if (encryptedPayloadSize > mdat.size - 8) throw new Error('样本总大小超过 mdat 数据范围')
|
|
||||||
|
|
||||||
const decryptedSamples: Uint8Array[] = []
|
|
||||||
let sampleOffset = mdat.offset + 8
|
|
||||||
for (let index = 0; index < sampleSizes.length; index += 1) {
|
|
||||||
const sampleSize = sampleSizes[index]
|
|
||||||
if (sampleOffset + sampleSize > fileData.length) throw new Error('音频样本数据不完整')
|
|
||||||
decryptedSamples.push(decryptCtr(fileData.subarray(sampleOffset, sampleOffset + sampleSize), keyBytes, ivs[index]))
|
|
||||||
sampleOffset += sampleSize
|
|
||||||
}
|
|
||||||
|
|
||||||
const flacMetadata = scanForFlacMetadata(stsd.data)
|
|
||||||
if (flacMetadata) {
|
|
||||||
const metadataStart = flacMetadata.length > 4 ? 4 : 0
|
|
||||||
const outputBuffer = Buffer.from(concatUint8Arrays([Buffer.from('fLaC'), flacMetadata.subarray(metadataStart), ...decryptedSamples]))
|
|
||||||
return {
|
|
||||||
buffer: outputBuffer,
|
|
||||||
extension: '.flac',
|
|
||||||
content_type: 'audio/flac',
|
|
||||||
codec: 'flac',
|
|
||||||
sample_count: sampleSizes.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = { sampleSizes, stscEntries, chunkCount }
|
|
||||||
const ftypSize = ftyp ? ftyp.size : 0
|
|
||||||
const dummyMoov = processBoxTree(fileData, moov.offset, moov.size, 0, context)
|
|
||||||
const newMdatOffset = ftypSize + dummyMoov.length + 16
|
|
||||||
const cleanMoovData = processBoxTree(fileData, moov.offset, moov.size, newMdatOffset, context)
|
|
||||||
const cleanMoov = concatUint8Arrays([writeUint32BE(cleanMoovData.length + 8), Buffer.from('moov'), cleanMoovData])
|
|
||||||
const mdatData = concatUint8Arrays(decryptedSamples)
|
|
||||||
const newMdat = concatUint8Arrays([writeUint32BE(mdatData.length + 8), Buffer.from('mdat'), mdatData])
|
|
||||||
const finalParts: Uint8Array[] = []
|
|
||||||
if (ftyp) finalParts.push(fileData.subarray(ftyp.offset, ftyp.offset + ftyp.size))
|
|
||||||
finalParts.push(cleanMoov, newMdat)
|
|
||||||
|
|
||||||
return {
|
|
||||||
buffer: Buffer.from(concatUint8Arrays(finalParts)),
|
|
||||||
extension: '.m4a',
|
|
||||||
content_type: 'audio/mp4',
|
|
||||||
codec: 'mp4',
|
|
||||||
sample_count: sampleSizes.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
decryptSpadeA,
|
|
||||||
isHexKey,
|
|
||||||
resolveAudioKey,
|
|
||||||
decryptAudioBuffer,
|
|
||||||
scanForFlacMetadata,
|
|
||||||
MP4Box,
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import md5 from "./md5.ts";
|
|
||||||
import { sm3 } from "./sm3.ts";
|
|
||||||
|
|
||||||
export function getCrypto(): Crypto {
|
|
||||||
const c = globalThis.crypto as Crypto | undefined;
|
|
||||||
if (!c?.subtle) {
|
|
||||||
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSubtle(): SubtleCrypto {
|
|
||||||
return getCrypto().subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function b64decode(b64: string): ArrayBuffer {
|
|
||||||
const binaryString = atob(b64);
|
|
||||||
const len = binaryString.length;
|
|
||||||
const bytes = new Uint8Array(len);
|
|
||||||
for (let i = 0; i < len; i++) {
|
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function b64encode(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const chunkSize = 0x8000;
|
|
||||||
const chunks: string[] = [];
|
|
||||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
||||||
chunks.push(
|
|
||||||
String.fromCharCode(...bytes.subarray(i, i + chunkSize)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return btoa(chunks.join(""));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function unhex(hex: string): ArrayBuffer {
|
|
||||||
if (hex.length % 2 !== 0) {
|
|
||||||
throw new Error("Invalid hex string");
|
|
||||||
}
|
|
||||||
const bytes = new Uint8Array(hex.length / 2);
|
|
||||||
for (let i = 0; i < hex.length; i += 2) {
|
|
||||||
const byte = parseInt(hex.slice(i, i + 2), 16);
|
|
||||||
if (Number.isNaN(byte)) {
|
|
||||||
throw new Error("Invalid hex string");
|
|
||||||
}
|
|
||||||
bytes[i / 2] = byte;
|
|
||||||
}
|
|
||||||
return bytes.buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hex(buffer: ArrayBuffer): string {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let hexString = "";
|
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
|
||||||
hexString += bytes[i]!.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
return hexString;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
|
|
||||||
const padLength = blockSize - (data.length % blockSize);
|
|
||||||
const padded = new Uint8Array(data.length + padLength);
|
|
||||||
padded.set(data);
|
|
||||||
padded.fill(padLength, data.length);
|
|
||||||
return padded;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function randomString(length: number): string {
|
|
||||||
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
||||||
let result = "";
|
|
||||||
const array = new Uint8Array(length);
|
|
||||||
getCrypto().getRandomValues(array);
|
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
result += chars.charAt(array[i]! % chars.length);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
type HashInput = ArrayBuffer | Uint8Array<ArrayBuffer> | string;
|
|
||||||
|
|
||||||
function toBytes(input: HashInput): Uint8Array<ArrayBuffer> {
|
|
||||||
if (typeof input === "string") {
|
|
||||||
return new TextEncoder().encode(input);
|
|
||||||
}
|
|
||||||
if (input instanceof Uint8Array) {
|
|
||||||
return new Uint8Array(input);
|
|
||||||
}
|
|
||||||
return new Uint8Array(input);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hash = {
|
|
||||||
sha256: async (input: HashInput): Promise<string> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const digest = await subtle.digest("SHA-256", toBytes(input));
|
|
||||||
return hex(digest);
|
|
||||||
},
|
|
||||||
sha256bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
return subtle.digest("SHA-256", toBytes(input));
|
|
||||||
},
|
|
||||||
sha512: async (input: HashInput): Promise<string> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
const digest = await subtle.digest("SHA-512", toBytes(input));
|
|
||||||
return hex(digest);
|
|
||||||
},
|
|
||||||
sha512bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
|
||||||
const subtle = getSubtle();
|
|
||||||
return subtle.digest("SHA-512", toBytes(input));
|
|
||||||
},
|
|
||||||
md5: async (input: HashInput): Promise<string> => md5(
|
|
||||||
typeof input === "string" ? input : toBytes(input).buffer,
|
|
||||||
),
|
|
||||||
md5bytes: async (input: HashInput): Promise<ArrayBuffer> => unhex(md5(
|
|
||||||
typeof input === "string" ? input : toBytes(input).buffer,
|
|
||||||
)),
|
|
||||||
sm3: async (input: HashInput): Promise<string> => hex(sm3(toBytes(input)).buffer),
|
|
||||||
sm3bytes: async (input: HashInput): Promise<ArrayBuffer> => sm3(toBytes(input)).buffer,
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { b64encode, getCrypto } from "./index.ts";
|
|
||||||
import { generateLadonKey, speckEncrypt } from "./speck.ts";
|
|
||||||
import type { UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
|
|
||||||
export async function getLadon(timestamp: number, config: UnidbgConfig): Promise<string> {
|
|
||||||
const randomBytes = getCrypto().getRandomValues(new Uint8Array(4));
|
|
||||||
|
|
||||||
const plaintext = new TextEncoder().encode(
|
|
||||||
`${timestamp}-${config.licenseId}-${config.aid}`,
|
|
||||||
);
|
|
||||||
const key = await generateLadonKey(randomBytes, config.aid);
|
|
||||||
const encrypted = speckEncrypt(key, plaintext);
|
|
||||||
|
|
||||||
const result = new Uint8Array(randomBytes.length + encrypted.length);
|
|
||||||
result.set(randomBytes);
|
|
||||||
result.set(encrypted, randomBytes.length);
|
|
||||||
return b64encode(result.buffer);
|
|
||||||
}
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
function rotateLeft(value: number, shiftBits: number): number {
|
|
||||||
return (value << shiftBits) | (value >>> (32 - shiftBits));
|
|
||||||
}
|
|
||||||
|
|
||||||
function addUnsigned(left: number, right: number): number {
|
|
||||||
return (left + right) >>> 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function f(x: number, y: number, z: number): number {
|
|
||||||
return (x & y) | (~x & z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function g(x: number, y: number, z: number): number {
|
|
||||||
return (x & z) | (y & ~z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function h(x: number, y: number, z: number): number {
|
|
||||||
return x ^ y ^ z;
|
|
||||||
}
|
|
||||||
|
|
||||||
function i(x: number, y: number, z: number): number {
|
|
||||||
return y ^ (x | ~z);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ff(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function gg(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hh(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ii(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
|
||||||
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b);
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertToWordArray(bytes: Uint8Array): number[] {
|
|
||||||
const wordCount = Math.ceil((bytes.length + 9) / 64) * 16;
|
|
||||||
const words = new Array<number>(wordCount).fill(0);
|
|
||||||
|
|
||||||
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
|
|
||||||
const wordIndex = Math.floor(byteIndex / 4);
|
|
||||||
const bytePosition = (byteIndex % 4) * 8;
|
|
||||||
words[wordIndex] = words[wordIndex]! | (bytes[byteIndex]! << bytePosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const paddingWordIndex = Math.floor(bytes.length / 4);
|
|
||||||
const paddingBytePosition = (bytes.length % 4) * 8;
|
|
||||||
words[paddingWordIndex] = words[paddingWordIndex]! | (0x80 << paddingBytePosition);
|
|
||||||
words[wordCount - 2] = bytes.length << 3;
|
|
||||||
words[wordCount - 1] = bytes.length >>> 29;
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wordToHex(value: number): string {
|
|
||||||
let hex = "";
|
|
||||||
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
|
|
||||||
const byte = (value >>> (byteIndex * 8)) & 0xff;
|
|
||||||
hex += byte.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
return hex;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算 MD5 摘要。
|
|
||||||
*/
|
|
||||||
export default function md5(input: string | ArrayBuffer): string {
|
|
||||||
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
|
|
||||||
const words = convertToWordArray(bytes);
|
|
||||||
|
|
||||||
let a = 0x67452301;
|
|
||||||
let b = 0xefcdab89;
|
|
||||||
let c = 0x98badcfe;
|
|
||||||
let d = 0x10325476;
|
|
||||||
|
|
||||||
for (let k = 0; k < words.length; k += 16) {
|
|
||||||
const aa = a;
|
|
||||||
const bb = b;
|
|
||||||
const cc = c;
|
|
||||||
const dd = d;
|
|
||||||
|
|
||||||
a = ff(a, b, c, d, words[k]!, 7, 0xd76aa478);
|
|
||||||
d = ff(d, a, b, c, words[k + 1]!, 12, 0xe8c7b756);
|
|
||||||
c = ff(c, d, a, b, words[k + 2]!, 17, 0x242070db);
|
|
||||||
b = ff(b, c, d, a, words[k + 3]!, 22, 0xc1bdceee);
|
|
||||||
a = ff(a, b, c, d, words[k + 4]!, 7, 0xf57c0faf);
|
|
||||||
d = ff(d, a, b, c, words[k + 5]!, 12, 0x4787c62a);
|
|
||||||
c = ff(c, d, a, b, words[k + 6]!, 17, 0xa8304613);
|
|
||||||
b = ff(b, c, d, a, words[k + 7]!, 22, 0xfd469501);
|
|
||||||
a = ff(a, b, c, d, words[k + 8]!, 7, 0x698098d8);
|
|
||||||
d = ff(d, a, b, c, words[k + 9]!, 12, 0x8b44f7af);
|
|
||||||
c = ff(c, d, a, b, words[k + 10]!, 17, 0xffff5bb1);
|
|
||||||
b = ff(b, c, d, a, words[k + 11]!, 22, 0x895cd7be);
|
|
||||||
a = ff(a, b, c, d, words[k + 12]!, 7, 0x6b901122);
|
|
||||||
d = ff(d, a, b, c, words[k + 13]!, 12, 0xfd987193);
|
|
||||||
c = ff(c, d, a, b, words[k + 14]!, 17, 0xa679438e);
|
|
||||||
b = ff(b, c, d, a, words[k + 15]!, 22, 0x49b40821);
|
|
||||||
|
|
||||||
a = gg(a, b, c, d, words[k + 1]!, 5, 0xf61e2562);
|
|
||||||
d = gg(d, a, b, c, words[k + 6]!, 9, 0xc040b340);
|
|
||||||
c = gg(c, d, a, b, words[k + 11]!, 14, 0x265e5a51);
|
|
||||||
b = gg(b, c, d, a, words[k]!, 20, 0xe9b6c7aa);
|
|
||||||
a = gg(a, b, c, d, words[k + 5]!, 5, 0xd62f105d);
|
|
||||||
d = gg(d, a, b, c, words[k + 10]!, 9, 0x02441453);
|
|
||||||
c = gg(c, d, a, b, words[k + 15]!, 14, 0xd8a1e681);
|
|
||||||
b = gg(b, c, d, a, words[k + 4]!, 20, 0xe7d3fbc8);
|
|
||||||
a = gg(a, b, c, d, words[k + 9]!, 5, 0x21e1cde6);
|
|
||||||
d = gg(d, a, b, c, words[k + 14]!, 9, 0xc33707d6);
|
|
||||||
c = gg(c, d, a, b, words[k + 3]!, 14, 0xf4d50d87);
|
|
||||||
b = gg(b, c, d, a, words[k + 8]!, 20, 0x455a14ed);
|
|
||||||
a = gg(a, b, c, d, words[k + 13]!, 5, 0xa9e3e905);
|
|
||||||
d = gg(d, a, b, c, words[k + 2]!, 9, 0xfcefa3f8);
|
|
||||||
c = gg(c, d, a, b, words[k + 7]!, 14, 0x676f02d9);
|
|
||||||
b = gg(b, c, d, a, words[k + 12]!, 20, 0x8d2a4c8a);
|
|
||||||
|
|
||||||
a = hh(a, b, c, d, words[k + 5]!, 4, 0xfffa3942);
|
|
||||||
d = hh(d, a, b, c, words[k + 8]!, 11, 0x8771f681);
|
|
||||||
c = hh(c, d, a, b, words[k + 11]!, 16, 0x6d9d6122);
|
|
||||||
b = hh(b, c, d, a, words[k + 14]!, 23, 0xfde5380c);
|
|
||||||
a = hh(a, b, c, d, words[k + 1]!, 4, 0xa4beea44);
|
|
||||||
d = hh(d, a, b, c, words[k + 4]!, 11, 0x4bdecfa9);
|
|
||||||
c = hh(c, d, a, b, words[k + 7]!, 16, 0xf6bb4b60);
|
|
||||||
b = hh(b, c, d, a, words[k + 10]!, 23, 0xbebfbc70);
|
|
||||||
a = hh(a, b, c, d, words[k + 13]!, 4, 0x289b7ec6);
|
|
||||||
d = hh(d, a, b, c, words[k]!, 11, 0xeaa127fa);
|
|
||||||
c = hh(c, d, a, b, words[k + 3]!, 16, 0xd4ef3085);
|
|
||||||
b = hh(b, c, d, a, words[k + 6]!, 23, 0x04881d05);
|
|
||||||
a = hh(a, b, c, d, words[k + 9]!, 4, 0xd9d4d039);
|
|
||||||
d = hh(d, a, b, c, words[k + 12]!, 11, 0xe6db99e5);
|
|
||||||
c = hh(c, d, a, b, words[k + 15]!, 16, 0x1fa27cf8);
|
|
||||||
b = hh(b, c, d, a, words[k + 2]!, 23, 0xc4ac5665);
|
|
||||||
|
|
||||||
a = ii(a, b, c, d, words[k]!, 6, 0xf4292244);
|
|
||||||
d = ii(d, a, b, c, words[k + 7]!, 10, 0x432aff97);
|
|
||||||
c = ii(c, d, a, b, words[k + 14]!, 15, 0xab9423a7);
|
|
||||||
b = ii(b, c, d, a, words[k + 5]!, 21, 0xfc93a039);
|
|
||||||
a = ii(a, b, c, d, words[k + 12]!, 6, 0x655b59c3);
|
|
||||||
d = ii(d, a, b, c, words[k + 3]!, 10, 0x8f0ccc92);
|
|
||||||
c = ii(c, d, a, b, words[k + 10]!, 15, 0xffeff47d);
|
|
||||||
b = ii(b, c, d, a, words[k + 1]!, 21, 0x85845dd1);
|
|
||||||
a = ii(a, b, c, d, words[k + 8]!, 6, 0x6fa87e4f);
|
|
||||||
d = ii(d, a, b, c, words[k + 15]!, 10, 0xfe2ce6e0);
|
|
||||||
c = ii(c, d, a, b, words[k + 6]!, 15, 0xa3014314);
|
|
||||||
b = ii(b, c, d, a, words[k + 13]!, 21, 0x4e0811a1);
|
|
||||||
a = ii(a, b, c, d, words[k + 4]!, 6, 0xf7537e82);
|
|
||||||
d = ii(d, a, b, c, words[k + 11]!, 10, 0xbd3af235);
|
|
||||||
c = ii(c, d, a, b, words[k + 2]!, 15, 0x2ad7d2bb);
|
|
||||||
b = ii(b, c, d, a, words[k + 9]!, 21, 0xeb86d391);
|
|
||||||
|
|
||||||
a = addUnsigned(a, aa);
|
|
||||||
b = addUnsigned(b, bb);
|
|
||||||
c = addUnsigned(c, cc);
|
|
||||||
d = addUnsigned(d, dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
const WIRE_VARINT = 0;
|
|
||||||
const WIRE_BYTES = 2;
|
|
||||||
const WIRE_FIXED32 = 5;
|
|
||||||
|
|
||||||
export class ProtobufWriter {
|
|
||||||
private readonly buf: number[] = [];
|
|
||||||
|
|
||||||
private writeVarint(value: number): this {
|
|
||||||
let v = value >>> 0;
|
|
||||||
while (v >= 0x80) {
|
|
||||||
this.buf.push((v & 0x7f) | 0x80);
|
|
||||||
v >>>= 7;
|
|
||||||
}
|
|
||||||
this.buf.push(v & 0x7f);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
private writeKey(fieldNumber: number, wireType: number): this {
|
|
||||||
return this.writeVarint((fieldNumber << 3) | wireType);
|
|
||||||
}
|
|
||||||
|
|
||||||
varint(fieldNumber: number, value: number): this {
|
|
||||||
return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
fixed32(fieldNumber: number, value: number): this {
|
|
||||||
this.writeKey(fieldNumber, WIRE_FIXED32);
|
|
||||||
const v = value >>> 0;
|
|
||||||
this.buf.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
bytes(fieldNumber: number, data: ArrayLike<number>): this {
|
|
||||||
this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length);
|
|
||||||
for (let i = 0; i < data.length; i++) {
|
|
||||||
this.buf.push(data[i]! & 0xff);
|
|
||||||
}
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
string(fieldNumber: number, value: string): this {
|
|
||||||
return this.bytes(fieldNumber, new TextEncoder().encode(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
message(fieldNumber: number, build: (writer: ProtobufWriter) => void): this {
|
|
||||||
const sub = new ProtobufWriter();
|
|
||||||
build(sub);
|
|
||||||
return this.bytes(fieldNumber, sub.toBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
toBytes(): Uint8Array<ArrayBuffer> {
|
|
||||||
return Uint8Array.from(this.buf);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { hash } from "./index.ts";
|
|
||||||
import { getArgus } from "./argus.ts";
|
|
||||||
import { getLadon } from "./ladon.ts";
|
|
||||||
import { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
|
|
||||||
export interface UnidbgHeaders extends Record<string, string> {
|
|
||||||
"x-argus": string;
|
|
||||||
"x-ladon": string;
|
|
||||||
"x-khronos": string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function generateHeaders(
|
|
||||||
rawQuery: string,
|
|
||||||
xssStub = "",
|
|
||||||
timestamp: number = Math.floor(Date.now() / 1000),
|
|
||||||
config: UnidbgConfig = defaultUnidbgConfig,
|
|
||||||
): Promise<UnidbgHeaders> {
|
|
||||||
const [argus, ladon] = await Promise.all([
|
|
||||||
getArgus(rawQuery, xssStub, timestamp, config),
|
|
||||||
getLadon(timestamp, config),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
"x-argus": argus,
|
|
||||||
"x-ladon": ladon,
|
|
||||||
"x-khronos": String(timestamp),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function signRequest(
|
|
||||||
url: string,
|
|
||||||
body?: ArrayBuffer | Uint8Array<ArrayBuffer> | string,
|
|
||||||
config: UnidbgConfig = defaultUnidbgConfig,
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
const rawQuery = new URL(url).search.replace(/^\?/, "");
|
|
||||||
|
|
||||||
const hasBody = typeof body === "string" ? body.length > 0 : (body?.byteLength ?? 0) > 0;
|
|
||||||
const xssStub = hasBody ? await hash.md5(body!) : "";
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const headers: Record<string, string> = await generateHeaders(
|
|
||||||
rawQuery,
|
|
||||||
xssStub,
|
|
||||||
Math.floor(now / 1000),
|
|
||||||
config,
|
|
||||||
);
|
|
||||||
headers["x-ss-req-ticket"] = String(now);
|
|
||||||
if (hasBody) {
|
|
||||||
headers["X-SS-STUB"] = xssStub;
|
|
||||||
}
|
|
||||||
return headers;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { getArgus } from "./argus.ts";
|
|
||||||
export { getLadon } from "./ladon.ts";
|
|
||||||
export { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
const ROUNDS = 72;
|
|
||||||
const MASK64 = 0xffffffffffffffffn;
|
|
||||||
|
|
||||||
const Z4 = 0x3dc94c3a046d678bn;
|
|
||||||
|
|
||||||
function getBit(value: bigint, position: number): bigint {
|
|
||||||
return (value >> BigInt(position)) & 1n;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateLeft64(v: bigint, n: bigint): bigint {
|
|
||||||
return ((v << n) | (v >> (64n - n))) & MASK64;
|
|
||||||
}
|
|
||||||
|
|
||||||
function rotateRight64(v: bigint, n: bigint): bigint {
|
|
||||||
return ((v << (64n - n)) | (v >> n)) & MASK64;
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyExpansion(key: readonly [bigint, bigint, bigint, bigint]): bigint[] {
|
|
||||||
const k: bigint[] = [key[0] & MASK64, key[1] & MASK64, key[2] & MASK64, key[3] & MASK64];
|
|
||||||
|
|
||||||
for (let i = 4; i < ROUNDS; i++) {
|
|
||||||
let tmp = rotateRight64(k[i - 1]!, 3n);
|
|
||||||
tmp ^= k[i - 3]!;
|
|
||||||
tmp ^= rotateRight64(tmp, 1n);
|
|
||||||
k.push((~k[i - 4]! ^ tmp ^ getBit(Z4, (i - 4) % 62) ^ 3n) & MASK64);
|
|
||||||
}
|
|
||||||
return k;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function simonEncrypt(
|
|
||||||
plaintext: readonly [bigint, bigint],
|
|
||||||
key: readonly [bigint, bigint, bigint, bigint],
|
|
||||||
): [bigint, bigint] {
|
|
||||||
const k = keyExpansion(key);
|
|
||||||
|
|
||||||
let x = plaintext[0] & MASK64;
|
|
||||||
let y = plaintext[1] & MASK64;
|
|
||||||
|
|
||||||
for (let i = 0; i < ROUNDS; i++) {
|
|
||||||
const tmp = y;
|
|
||||||
const f = rotateLeft64(y, 1n) & rotateLeft64(y, 8n);
|
|
||||||
y = (x ^ f ^ rotateLeft64(y, 2n) ^ k[i]!) & MASK64;
|
|
||||||
x = tmp;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [x, y];
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js
|
|
||||||
|
|
||||||
type Bytes = ArrayLike<number>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 循环左移
|
|
||||||
*/
|
|
||||||
function rotl(value: number, shift: number): number {
|
|
||||||
const normalizedShift = shift & 31;
|
|
||||||
return (value << normalizedShift) | (value >>> (32 - normalizedShift));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 二进制异或运算
|
|
||||||
*/
|
|
||||||
function xor(left: Bytes, right: Bytes): Uint8Array {
|
|
||||||
if (left.length !== right.length) {
|
|
||||||
throw new Error("XOR inputs must have the same length");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = new Uint8Array(left.length);
|
|
||||||
for (let i = 0; i < left.length; i++) {
|
|
||||||
result[i] = (left[i]! ^ right[i]!) & 0xff;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 压缩函数中的置换函数 P0(X) = X xor (X <<< 9) xor (X <<< 17)
|
|
||||||
*/
|
|
||||||
function p0(value: number): number {
|
|
||||||
return (value ^ rotl(value, 9)) ^ rotl(value, 17);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 消息扩展中的置换函数 P1(X) = X xor (X <<< 15) xor (X <<< 23)
|
|
||||||
*/
|
|
||||||
function p1(value: number): number {
|
|
||||||
return (value ^ rotl(value, 15)) ^ rotl(value, 23);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算 SM3 摘要。
|
|
||||||
*/
|
|
||||||
export function sm3(input: Bytes): Uint8Array<ArrayBuffer> {
|
|
||||||
const inputLength = input.length;
|
|
||||||
const paddedLength = Math.ceil((inputLength + 9) / 64) * 64;
|
|
||||||
const message = new Uint8Array(paddedLength);
|
|
||||||
message.set(input);
|
|
||||||
message[inputLength] = 0x80;
|
|
||||||
|
|
||||||
const bitLength = BigInt(inputLength) * 8n;
|
|
||||||
for (let i = 0; i < 8; i++) {
|
|
||||||
message[paddedLength - 1 - i] = Number((bitLength >> BigInt(i * 8)) & 0xffn);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dataView = new DataView(message.buffer);
|
|
||||||
const state = new Uint32Array([
|
|
||||||
0x7380166f,
|
|
||||||
0x4914b2b9,
|
|
||||||
0x172442d7,
|
|
||||||
0xda8a0600,
|
|
||||||
0xa96f30bc,
|
|
||||||
0x163138aa,
|
|
||||||
0xe38dee4d,
|
|
||||||
0xb0fb0e4e,
|
|
||||||
]);
|
|
||||||
const words = new Uint32Array(68);
|
|
||||||
const expandedWords = new Uint32Array(64);
|
|
||||||
|
|
||||||
for (let block = 0; block < message.length / 64; block++) {
|
|
||||||
const start = block * 64;
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
words[i] = dataView.getUint32(start + i * 4, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 16; i < 68; i++) {
|
|
||||||
words[i] = (p1((words[i - 16]! ^ words[i - 9]!) ^ rotl(words[i - 3]!, 15))
|
|
||||||
^ rotl(words[i - 13]!, 7))
|
|
||||||
^ words[i - 6]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
expandedWords[i] = words[i]! ^ words[i + 4]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
let a = state[0]!;
|
|
||||||
let b = state[1]!;
|
|
||||||
let c = state[2]!;
|
|
||||||
let d = state[3]!;
|
|
||||||
let e = state[4]!;
|
|
||||||
let f = state[5]!;
|
|
||||||
let g = state[6]!;
|
|
||||||
let h = state[7]!;
|
|
||||||
|
|
||||||
for (let i = 0; i < 64; i++) {
|
|
||||||
const t = i <= 15 ? 0x79cc4519 : 0x7a879d8a;
|
|
||||||
const ss1 = rotl(rotl(a, 12) + e + rotl(t, i), 7);
|
|
||||||
const ss2 = ss1 ^ rotl(a, 12);
|
|
||||||
const tt1 = (i <= 15 ? (a ^ b) ^ c : ((a & b) | (a & c)) | (b & c))
|
|
||||||
+ d + ss2 + expandedWords[i]!;
|
|
||||||
const tt2 = (i <= 15 ? (e ^ f) ^ g : (e & f) | (~e & g))
|
|
||||||
+ h + ss1 + words[i]!;
|
|
||||||
|
|
||||||
d = c;
|
|
||||||
c = rotl(b, 9);
|
|
||||||
b = a;
|
|
||||||
a = tt1;
|
|
||||||
h = g;
|
|
||||||
g = rotl(f, 19);
|
|
||||||
f = e;
|
|
||||||
e = p0(tt2);
|
|
||||||
}
|
|
||||||
|
|
||||||
state[0] = state[0]! ^ a;
|
|
||||||
state[1] = state[1]! ^ b;
|
|
||||||
state[2] = state[2]! ^ c;
|
|
||||||
state[3] = state[3]! ^ d;
|
|
||||||
state[4] = state[4]! ^ e;
|
|
||||||
state[5] = state[5]! ^ f;
|
|
||||||
state[6] = state[6]! ^ g;
|
|
||||||
state[7] = state[7]! ^ h;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = new Uint8Array(32);
|
|
||||||
for (let i = 0; i < state.length; i++) {
|
|
||||||
const word = state[i]!;
|
|
||||||
result[i * 4] = word >>> 24;
|
|
||||||
result[i * 4 + 1] = word >>> 16;
|
|
||||||
result[i * 4 + 2] = word >>> 8;
|
|
||||||
result[i * 4 + 3] = word;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算 HMAC-SM3。
|
|
||||||
*/
|
|
||||||
export function hmac(input: Bytes, key: Bytes): Uint8Array<ArrayBuffer> {
|
|
||||||
const blockLength = 64;
|
|
||||||
const normalizedKey = key.length > blockLength ? sm3(key) : Uint8Array.from(key);
|
|
||||||
const paddedKey = new Uint8Array(blockLength);
|
|
||||||
paddedKey.set(normalizedKey);
|
|
||||||
|
|
||||||
const innerPad = new Uint8Array(blockLength).fill(0x36);
|
|
||||||
const outerPad = new Uint8Array(blockLength).fill(0x5c);
|
|
||||||
const innerKey = xor(paddedKey, innerPad);
|
|
||||||
const outerKey = xor(paddedKey, outerPad);
|
|
||||||
|
|
||||||
const innerMessage = new Uint8Array(blockLength + input.length);
|
|
||||||
innerMessage.set(innerKey);
|
|
||||||
innerMessage.set(input, blockLength);
|
|
||||||
|
|
||||||
const outerMessage = new Uint8Array(blockLength + 32);
|
|
||||||
outerMessage.set(outerKey);
|
|
||||||
outerMessage.set(sm3(innerMessage), blockLength);
|
|
||||||
return sm3(outerMessage);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { hash, pkcs7Pad } from "./index.ts";
|
|
||||||
|
|
||||||
const ROUNDS = 34;
|
|
||||||
const MASK64 = 0xffffffffffffffffn;
|
|
||||||
const WORD_SIZE = 64n;
|
|
||||||
const ALPHA = 8n;
|
|
||||||
const BETA = 3n;
|
|
||||||
|
|
||||||
function readUint64LE(view: DataView, offset: number): bigint {
|
|
||||||
return view.getBigUint64(offset, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function keySchedule(key: Uint8Array): bigint[] {
|
|
||||||
const view = new DataView(key.buffer, key.byteOffset, key.byteLength);
|
|
||||||
|
|
||||||
const ks: bigint[] = [readUint64LE(view, 0) & MASK64];
|
|
||||||
const numWords = (key.length * 8) / Number(WORD_SIZE);
|
|
||||||
const ls: bigint[] = [];
|
|
||||||
for (let i = 1; i < numWords; i++) {
|
|
||||||
ls.push(readUint64LE(view, i * 8) & MASK64);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let x = 0; x < ROUNDS - 1; x++) {
|
|
||||||
const rsX = (((ls[x]! << (WORD_SIZE - ALPHA)) + (ls[x]! >> ALPHA)) & MASK64);
|
|
||||||
const addSxy = (rsX + ks[x]!) & MASK64;
|
|
||||||
const newX = BigInt(x) ^ addSxy;
|
|
||||||
const lsY = (((ks[x]! >> (WORD_SIZE - BETA)) + (ks[x]! << BETA)) & MASK64);
|
|
||||||
ls.push(newX);
|
|
||||||
ks.push(newX ^ lsY);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ks;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function encryptBlock(ks: readonly bigint[], block: Uint8Array, out: Uint8Array, outOffset: number): void {
|
|
||||||
const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
|
|
||||||
let y = readUint64LE(view, 0);
|
|
||||||
let x = readUint64LE(view, 8);
|
|
||||||
|
|
||||||
for (const k of ks) {
|
|
||||||
const rsX = (((x << (WORD_SIZE - ALPHA)) + (x >> ALPHA)) & MASK64);
|
|
||||||
const addSxy = (rsX + y) & MASK64;
|
|
||||||
x = k ^ addSxy;
|
|
||||||
const lsY = (((y >> (WORD_SIZE - BETA)) + (y << BETA)) & MASK64);
|
|
||||||
y = x ^ lsY;
|
|
||||||
}
|
|
||||||
|
|
||||||
const outView = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
|
||||||
outView.setBigUint64(outOffset, y & MASK64, true);
|
|
||||||
outView.setBigUint64(outOffset + 8, x & MASK64, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function speckEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
||||||
if (key.length !== 32) {
|
|
||||||
throw new Error(`Speck key must be 32 bytes, got ${key.length}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const padded = pkcs7Pad(plaintext, 16);
|
|
||||||
const ks = keySchedule(key);
|
|
||||||
|
|
||||||
const out = new Uint8Array(padded.length);
|
|
||||||
for (let i = 0; i < padded.length; i += 16) {
|
|
||||||
encryptBlock(ks, padded.subarray(i, i + 16), out, i);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function generateLadonKey(randomBytes: Uint8Array, aid: string): Promise<Uint8Array<ArrayBuffer>> {
|
|
||||||
const aidBytes = new TextEncoder().encode(aid);
|
|
||||||
const input = new Uint8Array(randomBytes.length + aidBytes.length);
|
|
||||||
input.set(randomBytes);
|
|
||||||
input.set(aidBytes, randomBytes.length);
|
|
||||||
|
|
||||||
const hex = await hash.md5(input);
|
|
||||||
return new TextEncoder().encode(hex);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
// TT 加密(设备注册请求体):gzip + SHA512 校验 + AES-128-CBC,magic 头 "tc"
|
|
||||||
// 移植自 fanqie-assistant src/crypto/ttencrypt.ts(Node crypto 实现)
|
|
||||||
import { createCipheriv, createHash, randomBytes } from 'node:crypto'
|
|
||||||
import { gzipSync } from 'node:zlib'
|
|
||||||
|
|
||||||
// 固定盐(base64 常量,社区共知)
|
|
||||||
const FIXED_STRING = Buffer.from(
|
|
||||||
'TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==',
|
|
||||||
'base64',
|
|
||||||
)
|
|
||||||
|
|
||||||
const sha512 = (data: Buffer): Buffer => createHash('sha512').update(data).digest()
|
|
||||||
|
|
||||||
export const encryptTT = (data: Buffer): Buffer => {
|
|
||||||
const rand = randomBytes(32)
|
|
||||||
const hashValue = sha512(Buffer.concat([sha512(rand), FIXED_STRING]))
|
|
||||||
const key = hashValue.subarray(0, 16)
|
|
||||||
const iv = hashValue.subarray(16, 32)
|
|
||||||
const compressed = gzipSync(data)
|
|
||||||
const payload = Buffer.concat([sha512(compressed), compressed])
|
|
||||||
const cipher = createCipheriv('aes-128-cbc', key, iv)
|
|
||||||
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()])
|
|
||||||
return Buffer.concat([Buffer.from([116, 99, 5, 16, 0, 0]), rand, encrypted])
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { encryptTT }
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// 汽水音乐(luna, aid=386088)签名配置
|
|
||||||
// signKey/licenseId 是字节 metasec SDK 家族(v04.04.05-ov-android)共用密钥——经 ssovit(TikTok
|
|
||||||
// aid=1233)/fanqie-assistant(番茄 aid=1967)/本项目(汽水 aid=386088) 三方同源实证;per-app 的只有
|
|
||||||
// 载荷里的 msAppID(argus protobuf field 4,由 config.aid 提供)。metasec license blob(native
|
|
||||||
// SDK 运行时 init 用)不参与离线签名。
|
|
||||||
import { unhex } from "./index.ts";
|
|
||||||
|
|
||||||
export interface UnidbgConfig {
|
|
||||||
signKey: Uint8Array;
|
|
||||||
aid: string;
|
|
||||||
licenseId: string;
|
|
||||||
sdkVersion: string;
|
|
||||||
sdkVersionInt: number;
|
|
||||||
callType: number;
|
|
||||||
secDeviceId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultUnidbgConfig: UnidbgConfig = {
|
|
||||||
signKey: new Uint8Array(
|
|
||||||
unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"),
|
|
||||||
),
|
|
||||||
aid: "386088",
|
|
||||||
licenseId: "1611921764",
|
|
||||||
sdkVersion: "v04.04.05-ov-android",
|
|
||||||
sdkVersionInt: 134744640,
|
|
||||||
callType: 738,
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
// 汽水音乐设备注册 —— i.snssdk.com 统一设备服务(按 body 里的 aid 分 app)
|
|
||||||
// 接口: POST https://i.snssdk.com/service/2/device_register/?tt_data=a(TT 加密 body,免签名)
|
|
||||||
// 实测(2026-10-01)aid=386088 可注册成功,返回 device_id/install_id/device_token。
|
|
||||||
// 设备身份持久化于 data/auth.json 的 _qs_device 字段(复用注册=同一台"设备",避免重复注册)
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
|
||||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
|
||||||
import { encryptTT } from './crypto/ttencrypt.ts'
|
|
||||||
|
|
||||||
const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a'
|
|
||||||
|
|
||||||
// 与签名查询参数一致的设备画像(注册后不再变化)
|
|
||||||
const DEVICE_FINGERPRINT = {
|
|
||||||
device_type: 'ABR-AL80',
|
|
||||||
device_brand: 'HUAWEI',
|
|
||||||
device_model: 'ABR-AL80',
|
|
||||||
os_version: '15',
|
|
||||||
os_api: 35,
|
|
||||||
resolution: '1080*1920',
|
|
||||||
dpi: 480,
|
|
||||||
channel: 'xiaomi_8478_64',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANDROID_VERSIONS = ['10', '11', '12', '13', '14']
|
|
||||||
const HEX_LOW = '0123456789abcdef'
|
|
||||||
const pick = (list: readonly string[]) => list[Math.floor(Math.random() * list.length)]
|
|
||||||
const randomHex = (k: number) => Array.from({ length: k }, () => HEX_LOW[Math.floor(Math.random() * 16)]).join('')
|
|
||||||
const uuid = () => crypto.randomUUID()
|
|
||||||
|
|
||||||
export interface QsDevice {
|
|
||||||
device_id: string
|
|
||||||
install_id: string
|
|
||||||
device_token?: string
|
|
||||||
device_type: string
|
|
||||||
device_brand: string
|
|
||||||
device_model: string
|
|
||||||
os_version: string
|
|
||||||
channel: string
|
|
||||||
openudid: string
|
|
||||||
cdid: string
|
|
||||||
registeredAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildRequestBody = () => {
|
|
||||||
const osVersion = pick(ANDROID_VERSIONS)
|
|
||||||
const genTime = Date.now()
|
|
||||||
return {
|
|
||||||
magic_tag: 'ss_app_log',
|
|
||||||
header: {
|
|
||||||
display_name: '汽水音乐',
|
|
||||||
aid: 386088,
|
|
||||||
channel: DEVICE_FINGERPRINT.channel,
|
|
||||||
package: 'com.luna.music',
|
|
||||||
sdk_version: '3.7.0-rc.25-luna-music',
|
|
||||||
sdk_target_version: 29,
|
|
||||||
git_hash: '711d1a7',
|
|
||||||
density_dpi: DEVICE_FINGERPRINT.dpi,
|
|
||||||
display_density: 'xhdpi',
|
|
||||||
resolution: DEVICE_FINGERPRINT.resolution,
|
|
||||||
language: 'zh',
|
|
||||||
timezone: 8,
|
|
||||||
access: 'wifi',
|
|
||||||
not_request_sender: 0,
|
|
||||||
carrier: 'CHINA MOBILE',
|
|
||||||
mcc_mnc: '46000',
|
|
||||||
region: 'CN',
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
tz_offset: 28800,
|
|
||||||
sim_region: 'cn',
|
|
||||||
sim_serial_number: [],
|
|
||||||
oaid_may_support: false,
|
|
||||||
device_platform: 'android',
|
|
||||||
custom: { host_bit: 32 },
|
|
||||||
pre_installed_channel: '',
|
|
||||||
is_system_app: 0,
|
|
||||||
sdk_flavor: 'china',
|
|
||||||
guest_mode: 0,
|
|
||||||
os: 'Android',
|
|
||||||
os_version: osVersion,
|
|
||||||
os_api: DEVICE_FINGERPRINT.os_api,
|
|
||||||
device_model: DEVICE_FINGERPRINT.device_model,
|
|
||||||
device_brand: DEVICE_FINGERPRINT.device_brand,
|
|
||||||
device_manufacturer: DEVICE_FINGERPRINT.device_brand,
|
|
||||||
cpu_abi: 'arm64-v8a',
|
|
||||||
release_build: randomHex(7),
|
|
||||||
cdid: uuid(),
|
|
||||||
sig_hash: 'a4a27c2633195374c15651ffc3c4a497',
|
|
||||||
openudid: randomHex(20),
|
|
||||||
clientudid: uuid(),
|
|
||||||
req_id: uuid(),
|
|
||||||
rom: randomHex(14).toUpperCase(),
|
|
||||||
apk_first_install_time: genTime - Math.floor(Math.random() * 365) * 86_400_000,
|
|
||||||
ipv6_list: [
|
|
||||||
{ type: 'client_anpi', value: '::1' },
|
|
||||||
{ type: 'client_anpi', value: 'fd9d:8e11:8c31:2a2c:0:0:0:1' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
_gen_time: genTime,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 注册新设备并持久化 */
|
|
||||||
export const registerDevice = async (): Promise<QsDevice> => {
|
|
||||||
const body = buildRequestBody()
|
|
||||||
const encrypted = encryptTT(Buffer.from(JSON.stringify(body)))
|
|
||||||
const request = httpFetch(REGISTER_URL, {
|
|
||||||
method: 'post',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'okhttp/4.10.0',
|
|
||||||
'Content-Type': 'application/octet-stream; tt-data=a',
|
|
||||||
},
|
|
||||||
body: encrypted,
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
const { statusCode, body: resBody } = await request.promise
|
|
||||||
const data = resBody as any
|
|
||||||
if (statusCode != 200 || !data?.device_id_str || !data?.install_id_str) {
|
|
||||||
throw new Error(`设备注册失败: HTTP ${statusCode} ${JSON.stringify(data).slice(0, 120)}`)
|
|
||||||
}
|
|
||||||
const device: QsDevice = {
|
|
||||||
device_id: String(data.device_id_str),
|
|
||||||
install_id: String(data.install_id_str),
|
|
||||||
device_token: data.device_token ? String(data.device_token) : '',
|
|
||||||
device_type: DEVICE_FINGERPRINT.device_type,
|
|
||||||
device_brand: DEVICE_FINGERPRINT.device_brand,
|
|
||||||
device_model: DEVICE_FINGERPRINT.device_model,
|
|
||||||
os_version: DEVICE_FINGERPRINT.os_version,
|
|
||||||
channel: DEVICE_FINGERPRINT.channel,
|
|
||||||
openudid: body.header.openudid,
|
|
||||||
cdid: body.header.cdid,
|
|
||||||
}
|
|
||||||
setCredentials('_qs_device', device as any)
|
|
||||||
console.log(`[qs/device] 设备注册成功: device_id=${device.device_id}`)
|
|
||||||
return device
|
|
||||||
}
|
|
||||||
|
|
||||||
let cachedDevice: QsDevice | null = null
|
|
||||||
|
|
||||||
/** 取当前设备(无则注册并持久化) */
|
|
||||||
export const getDevice = async (): Promise<QsDevice> => {
|
|
||||||
if (cachedDevice) return cachedDevice
|
|
||||||
const stored = getCredentials('_qs_device') as QsDevice | null
|
|
||||||
if (stored?.device_id && stored?.install_id) {
|
|
||||||
cachedDevice = stored
|
|
||||||
return cachedDevice
|
|
||||||
}
|
|
||||||
cachedDevice = await registerDevice()
|
|
||||||
return cachedDevice
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 设备是否已注册(不触发注册) */
|
|
||||||
export const hasDevice = (): boolean =>
|
|
||||||
Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设备身份(同步读,不触发注册):已注册设备优先;尚未注册时用独立生成的
|
|
||||||
* `_qs_passport_device`,避免覆盖注册记录。护照/签名模块共用,保证「同一设备」。
|
|
||||||
*/
|
|
||||||
export const getDeviceIds = (): { deviceId: string; installId: string } => {
|
|
||||||
const registered = getCredentials('_qs_device') as QsDevice | null
|
|
||||||
if (registered?.device_id && registered?.install_id) {
|
|
||||||
return { deviceId: String(registered.device_id), installId: String(registered.install_id) }
|
|
||||||
}
|
|
||||||
const existing = getCredentials('_qs_passport_device') as { deviceId?: string; installId?: string } | null
|
|
||||||
if (existing?.deviceId && existing.installId) return { deviceId: String(existing.deviceId), installId: String(existing.installId) }
|
|
||||||
const now = Date.now()
|
|
||||||
const ids = { deviceId: String(now), installId: String(now + 1) }
|
|
||||||
setCredentials('_qs_passport_device', ids)
|
|
||||||
return ids
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { registerDevice, getDevice, hasDevice, getDeviceIds }
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// 汽水音乐(字节跳动)平台模块
|
|
||||||
// 匿名可用面(2026-10-01 实测):搜索 / 曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。
|
|
||||||
// 登录态(/login/qs/qr 抖音扫码):设备注册 + X-Argus/X-Ladon 自签走 /luna/pc/track_v2,
|
|
||||||
// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测)
|
|
||||||
import qsApi, { getStreamBuffer } from '../apis/qs.ts'
|
|
||||||
import getLyric from './lyric.ts'
|
|
||||||
import getMusicInfo, { coverUrl } from './musicInfo.ts'
|
|
||||||
import musicSearch from './musicSearch.ts'
|
|
||||||
|
|
||||||
const qs = {
|
|
||||||
musicSearch,
|
|
||||||
getMusicUrl(songInfo, type) {
|
|
||||||
return qsApi.getMusicUrl(songInfo, type)
|
|
||||||
},
|
|
||||||
// 明文音频 Buffer(/stream/qs/:id 代理端点消费)
|
|
||||||
getStreamBuffer(trackId: string) {
|
|
||||||
return getStreamBuffer(trackId)
|
|
||||||
},
|
|
||||||
getLyric(songInfo) {
|
|
||||||
return getLyric(songInfo.songmid)
|
|
||||||
},
|
|
||||||
getPic(songInfo) {
|
|
||||||
return getMusicInfo(songInfo.songmid).promise.then(body => coverUrl(body?.seo_track?.track?.album))
|
|
||||||
},
|
|
||||||
getMusicDetailPageUrl(songInfo) {
|
|
||||||
return `https://music.douyin.com/qishui/share/track?track_id=${songInfo.songmid}`
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default qs
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
// 汽水音乐歌词(逐字时间轴 → LRC/YRC)
|
|
||||||
// 来源: seo_track 的 lyric.content,行格式 `[起(ms),长(ms)]<行内偏移(ms),长(ms),0>字...`
|
|
||||||
// 与网易云 yrc 同族(wy/lyric.ts 的 parseTools 处理 (a,b,c) 括号变体,这里是尖括号)
|
|
||||||
import musicInfo from './musicInfo.ts'
|
|
||||||
|
|
||||||
const lineRe = /^\[(\d+),\d+\]/
|
|
||||||
const wordRe = /<(\d+),(\d+),\d+>([^<]*)/g
|
|
||||||
|
|
||||||
const msFormat = (timeMs: number) => {
|
|
||||||
if (Number.isNaN(timeMs)) return ''
|
|
||||||
let ms = timeMs % 1000
|
|
||||||
timeMs /= 1000
|
|
||||||
const m = parseInt(String(timeMs / 60)).toString().padStart(2, '0')
|
|
||||||
timeMs %= 60
|
|
||||||
const s = parseInt(String(timeMs)).toString().padStart(2, '0')
|
|
||||||
return `[${m}:${s}.${ms}]`
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseTimedLyric = (content: string) => {
|
|
||||||
const lrcLines: string[] = []
|
|
||||||
const yrcLines: string[] = []
|
|
||||||
for (const rawLine of String(content || '').split('\n')) {
|
|
||||||
const line = rawLine.trim()
|
|
||||||
const lineMatch = lineRe.exec(line)
|
|
||||||
if (!lineMatch) continue
|
|
||||||
const timeTag = msFormat(parseInt(lineMatch[1]))
|
|
||||||
if (!timeTag) continue
|
|
||||||
const words = [...line.slice(lineMatch[0].length).matchAll(wordRe)]
|
|
||||||
if (!words.length) continue
|
|
||||||
const text = words.map(word => word[3]).join('')
|
|
||||||
if (!text.trim()) continue // 间奏空行
|
|
||||||
lrcLines.push(`${timeTag}${text}`)
|
|
||||||
yrcLines.push(`${timeTag}${words.map(word => `<${word[1]},${word[2]}>${word[3]}`).join('')}`)
|
|
||||||
}
|
|
||||||
return { lyric: lrcLines.join('\n'), yrc: yrcLines.join('\n') }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default (trackId: string) => {
|
|
||||||
const requestObj = musicInfo(trackId)
|
|
||||||
requestObj.promise = requestObj.promise.then(body => {
|
|
||||||
const info = parseTimedLyric(body?.lyric?.content)
|
|
||||||
if (!info.lyric) return Promise.reject(new Error('获取歌词失败'))
|
|
||||||
return { lyric: info.lyric, tlyric: '', yrc: info.yrc }
|
|
||||||
}) as any
|
|
||||||
return requestObj
|
|
||||||
}
|
|
||||||
-177
@@ -1,177 +0,0 @@
|
|||||||
// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 两种供给方式,按优先级:
|
|
||||||
// 1) 签名服务器:环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:8899,
|
|
||||||
// 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。
|
|
||||||
// 契约同时兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级)
|
|
||||||
// 的两个服务(请求 {url, headers, body?},响应 {ok, headers, added?}):
|
|
||||||
// - server.mjs (Wine + bdms.node)
|
|
||||||
// - mssdk-sign/ (metasecml.dll 独立进程,见其 README)
|
|
||||||
// 2) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 ——
|
|
||||||
// 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json:
|
|
||||||
// { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
|
|
||||||
// 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id
|
|
||||||
// (json 里带上即自动对齐 URL 参数),时效自负,被风控时走安卓通道兜底。
|
|
||||||
// 两者都没有:PC 通道不可用,搜索/取流自动走安卓自签通道。
|
|
||||||
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
|
|
||||||
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
|
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
import { dirname, join } from 'node:path'
|
|
||||||
import { fileURLToPath } from 'node:url'
|
|
||||||
import { getDeviceIds } from './device.ts'
|
|
||||||
|
|
||||||
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
|
|
||||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
||||||
const FIXED_SIGN_FILE = join(REPO_ROOT, 'data', 'qs-sign.json')
|
|
||||||
const SIGN_TIMEOUT = 5000
|
|
||||||
|
|
||||||
/** 固定值配置(helios/medusa 必填;device_id/install_id 应与抓包时同一套) */
|
|
||||||
interface FixedSignConfig {
|
|
||||||
helios: string
|
|
||||||
medusa: string
|
|
||||||
deviceId: string
|
|
||||||
installId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let fixedConfigWarned = false
|
|
||||||
|
|
||||||
/** 读固定值:环境变量 QS_HELIOS/QS_MEDUSA 优先,其次 data/qs-sign.json;没有返回 null */
|
|
||||||
const readFixedConfig = (): FixedSignConfig | null => {
|
|
||||||
const envHelios = process.env.QS_HELIOS?.trim()
|
|
||||||
const envMedusa = process.env.QS_MEDUSA?.trim()
|
|
||||||
if (envHelios && envMedusa) {
|
|
||||||
return {
|
|
||||||
helios: envHelios,
|
|
||||||
medusa: envMedusa,
|
|
||||||
deviceId: process.env.QS_SIGN_DEVICE_ID?.trim() ?? '',
|
|
||||||
installId: process.env.QS_SIGN_INSTALL_ID?.trim() ?? '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!existsSync(FIXED_SIGN_FILE)) return null
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(readFileSync(FIXED_SIGN_FILE, 'utf8')) as Record<string, unknown>
|
|
||||||
const helios = String(data.helios ?? '').trim()
|
|
||||||
const medusa = String(data.medusa ?? '').trim()
|
|
||||||
if (helios && medusa) {
|
|
||||||
return {
|
|
||||||
helios,
|
|
||||||
medusa,
|
|
||||||
deviceId: String(data.device_id ?? '').trim(),
|
|
||||||
installId: String(data.install_id ?? '').trim(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!fixedConfigWarned) {
|
|
||||||
fixedConfigWarned = true
|
|
||||||
console.log(`[qs/mssdk] ${FIXED_SIGN_FILE} 缺少 helios/medusa 字段,固定值模式不启用`)
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!fixedConfigWarned) {
|
|
||||||
fixedConfigWarned = true
|
|
||||||
console.log(`[qs/mssdk] 固定值配置解析失败: ${err?.message ?? err}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
type SignMode =
|
|
||||||
| { kind: 'server'; server: string; token: string }
|
|
||||||
| { kind: 'fixed'; fixed: FixedSignConfig }
|
|
||||||
|
|
||||||
/** 当前签名供给方式:签名服务器 > 固定值 > 不可用(null) */
|
|
||||||
const loadSignMode = (): SignMode | null => {
|
|
||||||
const server = process.env.QS_SIGN_SERVER?.trim().replace(/\/+$/, '')
|
|
||||||
if (server) return { kind: 'server', server, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' }
|
|
||||||
const fixed = readFixedConfig()
|
|
||||||
return fixed ? { kind: 'fixed', fixed } : null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PC 签名是否可用(只看配置;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */
|
|
||||||
export const mssdkAvailable = (): boolean => Boolean(loadSignMode())
|
|
||||||
|
|
||||||
const signViaServer = async (
|
|
||||||
server: string,
|
|
||||||
token: string,
|
|
||||||
url: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
body?: string,
|
|
||||||
): Promise<Record<string, string> | null> => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${server}/sign`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'content-type': 'application/json',
|
|
||||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ url, headers, body }),
|
|
||||||
signal: AbortSignal.timeout(SIGN_TIMEOUT),
|
|
||||||
})
|
|
||||||
const json = await res.json().catch(() => null) as any
|
|
||||||
if (!res.ok || !json?.ok) {
|
|
||||||
console.log(`[qs/mssdk] 签名服务返回异常: HTTP ${res.status} ${json?.error ?? ''}`.trim())
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
// signer/server.mjs 把 x-ss-stub 并进 headers,mssdk-sign 放 added —— 合并即可
|
|
||||||
const signed: Record<string, string> = { ...(json.headers ?? {}), ...(json.added ?? {}) }
|
|
||||||
if (!signed['X-Helios'] && !signed['x-helios']) {
|
|
||||||
console.log('[qs/mssdk] 签名结果缺少 X-Helios')
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return signed
|
|
||||||
} catch (err: any) {
|
|
||||||
console.log(`[qs/mssdk] 签名服务不可达: ${err?.message ?? err}`)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 给一个 qishui.com 请求签名
|
|
||||||
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
|
|
||||||
* @param body POST body(给了且 headers 没带 x-ss-stub 时服务端会自动补)
|
|
||||||
* @returns 需追加的头;不可用时返回 null(调用方走安卓通道)
|
|
||||||
*/
|
|
||||||
export const signQishuiRequest = async (
|
|
||||||
url: string,
|
|
||||||
headers: Record<string, string>,
|
|
||||||
body?: string,
|
|
||||||
): Promise<Record<string, string> | null> => {
|
|
||||||
const mode = loadSignMode()
|
|
||||||
if (!mode) return null
|
|
||||||
if (mode.kind === 'fixed') return { 'X-Helios': mode.fixed.helios, 'X-Medusa': mode.fixed.medusa }
|
|
||||||
return signViaServer(mode.server, mode.token, url, headers, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 官方 PC 端 UA(签名与 UA 绑定同一客户端版本) */
|
|
||||||
export const PC_UA = 'LunaPC/3.7.0(452316191)'
|
|
||||||
|
|
||||||
/** 官方 PC 端公共查询参数(与签名设备成套;track_v2 / search 等 luna_pc 接口共用) */
|
|
||||||
export const pcCommonParams = (): Record<string, string> => {
|
|
||||||
const mode = loadSignMode()
|
|
||||||
// 固定值是针对抓包设备签的,必须用配套设备;服务器现签则用本地设备(URL 带什么签什么)
|
|
||||||
const fixedDevice = mode?.kind === 'fixed' && mode.fixed.deviceId ? mode.fixed : null
|
|
||||||
const { deviceId, installId } = fixedDevice
|
|
||||||
? { deviceId: fixedDevice.deviceId, installId: fixedDevice.installId }
|
|
||||||
: getDeviceIds()
|
|
||||||
return {
|
|
||||||
aid: '386088',
|
|
||||||
app_name: 'luna_pc',
|
|
||||||
region: 'cn',
|
|
||||||
geo_region: 'cn',
|
|
||||||
os_region: 'cn',
|
|
||||||
sim_region: '',
|
|
||||||
device_id: deviceId,
|
|
||||||
cdid: '',
|
|
||||||
iid: installId,
|
|
||||||
version_name: '3.7.0',
|
|
||||||
version_code: '30070000',
|
|
||||||
channel: 'official',
|
|
||||||
build_mode: 'master',
|
|
||||||
network_carrier: '',
|
|
||||||
ac: 'wifi',
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
resolution: '',
|
|
||||||
device_platform: 'windows',
|
|
||||||
device_type: 'Windows',
|
|
||||||
os_version: 'Windows 11',
|
|
||||||
fp: deviceId,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
// 汽水音乐曲目信息(H5 SEO 通道)
|
|
||||||
// 接口: https://beta-luna.douyin.com/luna/h5/seo_track?track_id=<id>&device_platform=web
|
|
||||||
// 匿名可访问(无签名要求):返回曲目元数据(name/artists/album/duration/bit_rates/preview)、
|
|
||||||
// 逐字歌词(lyric.content)与试听播放信息(track_player.url_player_info / video_model)
|
|
||||||
// 注意:seo_track 属分享页/SEO 数据面,track_player 里只有 30 秒试听资源;
|
|
||||||
// 完整音频需真机凭证走 /luna/pc/track_v2,且音频经 CENC 加密,见 apis/qs.ts 头注释
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
|
||||||
import type { RequestObj } from '../types.ts'
|
|
||||||
|
|
||||||
export default (trackId: string): RequestObj<any> => {
|
|
||||||
const requestObj = httpFetch(`https://beta-luna.douyin.com/luna/h5/seo_track?track_id=${trackId}&device_platform=web`, {
|
|
||||||
method: 'get',
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
|
|
||||||
if (statusCode != 200 || !body?.seo_track?.track) return Promise.reject(new Error('获取歌曲信息失败'))
|
|
||||||
return body
|
|
||||||
})
|
|
||||||
return requestObj
|
|
||||||
}
|
|
||||||
// 专辑封面 URL:urls[0] 为 CDN 前缀,uri 为资源路径,拼尺寸模板得方形封面(社区通用做法)
|
|
||||||
export const coverUrl = (album: any): string | null => {
|
|
||||||
const urlCover = album?.url_cover
|
|
||||||
if (!urlCover?.uri) return null
|
|
||||||
const prefix = Array.isArray(urlCover.urls) && urlCover.urls.length ? urlCover.urls[0] : 'https://p3-luna.douyinpic.com/img/'
|
|
||||||
return `${prefix}${urlCover.uri}~c5_500x500.jpg`
|
|
||||||
}
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
// 汽水音乐搜索(App 搜索接口直连)
|
|
||||||
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
|
||||||
// 两条通道(同一接口,差在公共参数与签名,返回结构一致):
|
|
||||||
// 官方 PC 通道: PC 签名可用(签名服务器或固定值,见 sdk/qs/mssdk.ts)时优先——
|
|
||||||
// luna_pc 参数 + X-Helios/X-Medusa;签名失败/被风控自动降级安卓通道;
|
|
||||||
// 安卓指纹通道: 静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控(200 + 空 body);
|
|
||||||
// 被风控时需换新指纹。
|
|
||||||
import { sizeFormate, formatPlayTime } from '../index.ts'
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
|
||||||
import { coverUrl } from './musicInfo.ts'
|
|
||||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts'
|
|
||||||
|
|
||||||
// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新)
|
|
||||||
const SEARCH_DEVICE_PARAMS = {
|
|
||||||
device_platform: 'android',
|
|
||||||
os: 'android',
|
|
||||||
ssmix: 'a',
|
|
||||||
cdid: '46556f98-1720-4248-83da-62b74b60b46a',
|
|
||||||
channel: 'xiaomi_8478_64',
|
|
||||||
aid: '386088',
|
|
||||||
app_name: 'luna',
|
|
||||||
version_code: '100198030',
|
|
||||||
version_name: '19.8.0',
|
|
||||||
manifest_version_code: '100198030',
|
|
||||||
update_version_code: '100198030',
|
|
||||||
resolution: '1080*1920',
|
|
||||||
dpi: '480',
|
|
||||||
device_type: 'ABR-AL80',
|
|
||||||
device_brand: 'HUAWEI',
|
|
||||||
language: 'zh',
|
|
||||||
os_api: '35',
|
|
||||||
os_version: '15',
|
|
||||||
ac: 'wifi',
|
|
||||||
device_model: 'ABR-AL80',
|
|
||||||
save_power: '0',
|
|
||||||
font_size: '1.00',
|
|
||||||
luna_first_launch_apk_type: 'normal_apk',
|
|
||||||
diversion_channel_name: 'xiaomi_8478_64',
|
|
||||||
is_car_play: '0',
|
|
||||||
battery: '0.99',
|
|
||||||
network_speed: '10156',
|
|
||||||
hybrid_version_code: '100198030',
|
|
||||||
tz_name: 'Asia/Shanghai',
|
|
||||||
tz_offset: '28800',
|
|
||||||
luna_register_time: '1784311292',
|
|
||||||
package: 'com.luna.music',
|
|
||||||
charge: '0',
|
|
||||||
luna_apk_type: 'normal_apk',
|
|
||||||
output_device_type: 'Phone',
|
|
||||||
volume: '1.00',
|
|
||||||
brightness: '0.08',
|
|
||||||
need_personal_recommend: '1',
|
|
||||||
is_teen_mode: '0',
|
|
||||||
sim_region: 'cn',
|
|
||||||
android_device_type: 'default',
|
|
||||||
iid: '2204957404569386',
|
|
||||||
device_id: '2204957404565290',
|
|
||||||
}
|
|
||||||
|
|
||||||
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
|
|
||||||
const PC_PAGE_SIZE = 20
|
|
||||||
|
|
||||||
// PC 通道必须配 X-Helios/X-Medusa(否则上游 200 + 空 body);安卓通道用静态设备指纹
|
|
||||||
const buildSearchUrl = (str: string, cursor: number, count: number, pcChannel: boolean) => {
|
|
||||||
const params = pcChannel
|
|
||||||
? new URLSearchParams({
|
|
||||||
...pcCommonParams(),
|
|
||||||
q: str,
|
|
||||||
cursor: String(cursor),
|
|
||||||
search_id: '',
|
|
||||||
search_method: 'input',
|
|
||||||
debug_params: '',
|
|
||||||
from_search_id: '',
|
|
||||||
search_scene: 'normal',
|
|
||||||
scene_name: 'search',
|
|
||||||
})
|
|
||||||
: new URLSearchParams({
|
|
||||||
...SEARCH_DEVICE_PARAMS,
|
|
||||||
_rticket: String(Date.now()),
|
|
||||||
q: str,
|
|
||||||
cursor: String(cursor),
|
|
||||||
count: String(count),
|
|
||||||
})
|
|
||||||
return `https://api.qishui.com/luna/search/track?${params.toString()}`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
limit: 30,
|
|
||||||
total: 0,
|
|
||||||
page: 0,
|
|
||||||
allPage: 1,
|
|
||||||
getSinger(artists) {
|
|
||||||
return (artists || []).map(singer => singer.name).filter(Boolean).join('、')
|
|
||||||
},
|
|
||||||
handleResult(rawList) {
|
|
||||||
if (!rawList) return []
|
|
||||||
return rawList.map(item => {
|
|
||||||
const track = item?.entity?.track
|
|
||||||
if (!track?.id) return null
|
|
||||||
// types 反映匿名实际可获取的资源:30 秒试听(preview.bit_rates 为试听码率)
|
|
||||||
// track.bit_rates 是完整曲目码率元数据(66k~hi_res),但匿名无权取流,不对外声明
|
|
||||||
const previewSize = track.preview?.bit_rates?.[0]?.size
|
|
||||||
const size = previewSize ? sizeFormate(previewSize) : null
|
|
||||||
const types = [{ type: '128k', size }]
|
|
||||||
const _types = { '128k': { size } }
|
|
||||||
return {
|
|
||||||
singer: this.getSinger(track.artists),
|
|
||||||
name: track.name,
|
|
||||||
albumName: track.album?.name ?? null,
|
|
||||||
albumId: track.album?.id ?? null,
|
|
||||||
source: 'qs',
|
|
||||||
interval: formatPlayTime(track.duration / 1000),
|
|
||||||
songmid: String(track.id),
|
|
||||||
img: coverUrl(track.album),
|
|
||||||
lrc: null,
|
|
||||||
types,
|
|
||||||
_types,
|
|
||||||
typeUrl: {},
|
|
||||||
}
|
|
||||||
}).filter(Boolean)
|
|
||||||
},
|
|
||||||
handleSearchBody(rawBody, cursor, page) {
|
|
||||||
const group = rawBody.result_groups.find(g => g?.data?.length) || { data: [], has_more: false }
|
|
||||||
const list = this.handleResult(group.data)
|
|
||||||
const total = group.has_more ? cursor + list.length + 1 : cursor + list.length
|
|
||||||
this.total = total
|
|
||||||
this.page = page
|
|
||||||
this.allPage = group.has_more ? page + 1 : page
|
|
||||||
return {
|
|
||||||
list,
|
|
||||||
allPage: this.allPage,
|
|
||||||
limit: this.limit,
|
|
||||||
total,
|
|
||||||
source: 'qs',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async search(str, page = 1, limit, retryNum = 0, forceAndroid = false) {
|
|
||||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
|
||||||
const pcChannel = !forceAndroid && mssdkAvailable()
|
|
||||||
if (pcChannel) {
|
|
||||||
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
|
|
||||||
const pageSize = PC_PAGE_SIZE
|
|
||||||
const cursor = pageSize * (page - 1)
|
|
||||||
const url = buildSearchUrl(str, cursor, pageSize, true)
|
|
||||||
// 签名覆盖真实发出的头:先定头、再签、再发
|
|
||||||
const headers: Record<string, string> = { 'User-Agent': PC_UA }
|
|
||||||
const fallbackToAndroid = (reason: string) => {
|
|
||||||
console.log(`[qs/search] PC 通道${reason},降级安卓通道`)
|
|
||||||
return this.search(str, page, limit, retryNum, true)
|
|
||||||
}
|
|
||||||
let body: any = null
|
|
||||||
try {
|
|
||||||
// 签名失败(服务器宕机/固定值被风控)与请求异常都降级安卓通道
|
|
||||||
const signed = await signQishuiRequest(url, headers)
|
|
||||||
if (!signed) return fallbackToAndroid('签名不可用')
|
|
||||||
const searchRequest = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise
|
|
||||||
body = searchRequest.body
|
|
||||||
} catch (err: any) {
|
|
||||||
return fallbackToAndroid(`请求失败: ${err?.message ?? err}`)
|
|
||||||
}
|
|
||||||
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}})
|
|
||||||
if (!body?.result_groups) return fallbackToAndroid('空结果(疑似风控)')
|
|
||||||
return this.handleSearchBody(body, cursor, page)
|
|
||||||
}
|
|
||||||
// 安卓指纹通道:无签名直发,空结果时换 _rticket 重试
|
|
||||||
const pageSize = limit ?? this.limit
|
|
||||||
const cursor = pageSize * (page - 1)
|
|
||||||
const url = buildSearchUrl(str, cursor, pageSize, false)
|
|
||||||
const headers: Record<string, string> = { 'User-Agent': SEARCH_UA }
|
|
||||||
const { body } = await httpFetch(url, { method: 'get', headers, timeout: 15000 }).promise
|
|
||||||
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
|
|
||||||
return this.handleSearchBody(body, cursor, page)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
// 汽水音乐扫码登录 —— 复刻官方 PC 客户端(SodaMusic)的扫码链路
|
|
||||||
// 接口:
|
|
||||||
// GET https://api.qishui.com/passport/web/get_qrcode/ → token + 初始化 Cookie
|
|
||||||
// GET 二维码内容不是响应里的 qrcode 图,而是用 qrcode_index_url 的 token 重建的官方扫码页:
|
|
||||||
// https://bff-pc.qishui.com/light/invoke/scan_login?token=<token>&os=Windows&computer_name=<主机名>
|
|
||||||
// (官方 renderer getHandleScanCodeH5Img 即此算法;响应自带的 qrcode 图指向旧页
|
|
||||||
// ucenter_web/app/sdk-next,该页已 404,抖音扫码会提示「无法访问页面」)
|
|
||||||
// POST https://api.qishui.com/passport/web/check_qrconnect/ → 轮询状态,确认后 Set-Cookie 带 sessionid
|
|
||||||
// 状态映射沿用本仓库约定:800 过期/拒绝 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存)
|
|
||||||
import os from 'node:os'
|
|
||||||
import QRCode from 'qrcode'
|
|
||||||
import { httpFetch } from '../common/request.ts'
|
|
||||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
|
||||||
import { getDeviceIds } from './device.ts'
|
|
||||||
|
|
||||||
const PASSPORT_HOST = 'https://api.qishui.com'
|
|
||||||
const SCAN_LOGIN_URL = 'https://bff-pc.qishui.com/light/invoke/scan_login'
|
|
||||||
const WEB_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36'
|
|
||||||
|
|
||||||
const PASSPORT_FIXED = {
|
|
||||||
aid: '386088',
|
|
||||||
passport_jssdk_version: '2.4.13',
|
|
||||||
passport_jssdk_type: 'normal',
|
|
||||||
is_from_ttaccountsdk: '1',
|
|
||||||
next: 'https://api.qishui.com',
|
|
||||||
need_logo: 'false',
|
|
||||||
need_short_url: 'false',
|
|
||||||
is_frontier: 'true',
|
|
||||||
is_new_login: '1',
|
|
||||||
version_code: '30020100',
|
|
||||||
}
|
|
||||||
|
|
||||||
// 各 passport 请求共用的公共参数(设备身份 + 平台/版本)
|
|
||||||
const commonParams = (): Record<string, string> => {
|
|
||||||
const { deviceId, installId } = getDeviceIds()
|
|
||||||
return {
|
|
||||||
passport_jssdk_version: PASSPORT_FIXED.passport_jssdk_version,
|
|
||||||
passport_jssdk_type: PASSPORT_FIXED.passport_jssdk_type,
|
|
||||||
is_from_ttaccountsdk: PASSPORT_FIXED.is_from_ttaccountsdk,
|
|
||||||
aid: PASSPORT_FIXED.aid,
|
|
||||||
language: 'zh',
|
|
||||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
|
||||||
device_id: deviceId,
|
|
||||||
install_id: installId,
|
|
||||||
did: deviceId,
|
|
||||||
iid: installId,
|
|
||||||
device_platform: 'PC',
|
|
||||||
version_code: PASSPORT_FIXED.version_code,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const webHeaders = (cookie = '') => ({
|
|
||||||
'User-Agent': WEB_UA,
|
|
||||||
...(cookie ? { Cookie: cookie } : {}),
|
|
||||||
})
|
|
||||||
|
|
||||||
const cookieValues = (headers: Record<string, any>): string[] => {
|
|
||||||
const raw = headers?.['set-cookie']
|
|
||||||
return Array.isArray(raw) ? raw : raw ? [raw] : []
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookiePairsFrom = (headers: Record<string, any>): string[] =>
|
|
||||||
cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean)
|
|
||||||
|
|
||||||
const cookieMapFrom = (headers: Record<string, any>): Record<string, string> => {
|
|
||||||
const out: Record<string, string> = {}
|
|
||||||
for (const pair of cookiePairsFrom(headers)) {
|
|
||||||
const idx = pair.indexOf('=')
|
|
||||||
if (idx > 0) out[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeCookies = (current: string, fresh: Record<string, string>): string => {
|
|
||||||
for (const pair of current.split(';')) {
|
|
||||||
const idx = pair.indexOf('=')
|
|
||||||
if (idx > 0) {
|
|
||||||
const name = pair.slice(0, idx).trim()
|
|
||||||
if (name) fresh[name] = fresh[name] || pair.slice(idx + 1).trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Object.entries(fresh).map(([k, v]) => `${k}=${v}`).join('; ')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录态 cookie 名(passport 确认后下发;sessionid 为必需项)
|
|
||||||
const SESSION_COOKIE_NAMES = ['sessionid', 'sessionid_ss', 'sid_guard', 'sid_tt']
|
|
||||||
|
|
||||||
const sessionIdOf = (cookie: string): string =>
|
|
||||||
cookie.match(/(?:^|;\s*)sessionid=([^;]+)/)?.[1] ?? ''
|
|
||||||
|
|
||||||
// get_qrcode 的 Set-Cookie 需在轮询时原样带上(内存暂存,随二维码生命周期)
|
|
||||||
const qrCookies = new Map<string, string>()
|
|
||||||
|
|
||||||
/** 官方扫码页地址:响应 qrcode_index_url 里的 token + PC 标识(官方 renderer 同款算法) */
|
|
||||||
export const buildScanUrl = (indexUrl: string, computerName = os.hostname() || 'Windows-PC'): string => {
|
|
||||||
let token = ''
|
|
||||||
try {
|
|
||||||
token = new URL(String(indexUrl)).searchParams.get('token') || ''
|
|
||||||
} catch {
|
|
||||||
token = ''
|
|
||||||
}
|
|
||||||
if (!token) throw new Error(`二维码地址缺少 token: ${String(indexUrl).slice(0, 80)}`)
|
|
||||||
const url = new URL(SCAN_LOGIN_URL)
|
|
||||||
url.searchParams.set('token', token)
|
|
||||||
url.searchParams.set('os', 'Windows')
|
|
||||||
url.searchParams.set('computer_name', String(computerName).trim() || 'Windows-PC')
|
|
||||||
// 抖音/客户端把 + 解析成空格,官方实现同样显式替换
|
|
||||||
return url.toString().replace(/\+/g, '%20')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 创建扫码登录二维码(本地渲染官方扫码页,响应自带的 qrcode 图已失效不可用) */
|
|
||||||
export const createQr = async (): Promise<{ token: string; qrimg: string; expireTime: number; scanApp: string; scanUrl: string }> => {
|
|
||||||
const query = new URLSearchParams({
|
|
||||||
...commonParams(),
|
|
||||||
next: PASSPORT_FIXED.next,
|
|
||||||
need_logo: PASSPORT_FIXED.need_logo,
|
|
||||||
need_short_url: PASSPORT_FIXED.need_short_url,
|
|
||||||
})
|
|
||||||
const request = httpFetch(`${PASSPORT_HOST}/passport/web/get_qrcode/?${query}`, {
|
|
||||||
method: 'get',
|
|
||||||
headers: webHeaders(),
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
const { statusCode, headers, body } = await request.promise
|
|
||||||
const data = (body as any)?.data ?? {}
|
|
||||||
if (statusCode != 200 || !data.token) {
|
|
||||||
throw new Error(`获取二维码失败: HTTP ${statusCode} ${JSON.stringify(body).slice(0, 120)}`)
|
|
||||||
}
|
|
||||||
const scanUrl = buildScanUrl(data.qrcode_index_url ?? '', undefined)
|
|
||||||
const qrimg = await QRCode.toDataURL(scanUrl, { errorCorrectionLevel: 'M', margin: 2, width: 360 })
|
|
||||||
qrCookies.set(data.token, mergeCookies('', cookieMapFrom(headers)))
|
|
||||||
return {
|
|
||||||
token: String(data.token),
|
|
||||||
qrimg,
|
|
||||||
expireTime: Number(data.expire_time) || 0,
|
|
||||||
// 官方 PC 客户端扫码文案要求「打开汽水音乐 APP → 搜索框右侧 扫一扫」(passport 的
|
|
||||||
// copywriting 写的是抖音 APP,但扫描目标是 bff-pc 的 scan_login 页,实际须汽水 App)
|
|
||||||
scanApp: 'qishui',
|
|
||||||
scanUrl,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QrDecision {
|
|
||||||
state: 'waiting' | 'scanned' | 'confirmed' | 'expired' | 'failed' | 'mfa'
|
|
||||||
message: string
|
|
||||||
status: string
|
|
||||||
errorCode: number
|
|
||||||
rateLimited?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// 官方状态语义(SodaMusic renderer use-scan-code-login):
|
|
||||||
// 1/new 等待扫码;2/scanned 已扫码待确认;3/confirmed 已确认;4/5/refused/expired 过期或被拒
|
|
||||||
export const decideQrStatus = (data: Record<string, any>): QrDecision => {
|
|
||||||
const status = String(data?.status ?? '').toLowerCase()
|
|
||||||
const errorCode = Number(data?.error_code) || 0
|
|
||||||
const accountFlow = String(data?.account_flow ?? '').toLowerCase()
|
|
||||||
const description = String(data?.description ?? '')
|
|
||||||
|
|
||||||
if (errorCode === 2046 || accountFlow === 'verify') {
|
|
||||||
return { state: 'mfa', message: '需要手机端二次验证(本服务无法自动完成,请在汽水 App 内完成验证后重试)', status, errorCode }
|
|
||||||
}
|
|
||||||
if (errorCode === 7) {
|
|
||||||
return { state: 'waiting', message: '接口限流,请降低轮询频率', status, errorCode, rateLimited: true }
|
|
||||||
}
|
|
||||||
if (['3', 'confirmed'].includes(status)) {
|
|
||||||
return { state: 'confirmed', message: '已确认,等待登录态下发', status, errorCode }
|
|
||||||
}
|
|
||||||
if (['2', 'scanned'].includes(status)) {
|
|
||||||
return { state: 'scanned', message: '已扫码,待确认', status, errorCode }
|
|
||||||
}
|
|
||||||
if (['4', '5', 'refused', 'expired'].includes(status) || errorCode === 4 || /expire|过期|失效/i.test(description)) {
|
|
||||||
const refused = status === 'refused' || /refus|拒绝/i.test(description)
|
|
||||||
return { state: 'expired', message: refused ? '已在手机端拒绝登录' : '二维码已失效', status, errorCode }
|
|
||||||
}
|
|
||||||
if (['error', 'failed'].includes(status) || (errorCode !== 0 && status !== 'new' && status !== '1' && status !== '')) {
|
|
||||||
return { state: 'failed', message: description || `扫码失败 (code=${errorCode})`, status, errorCode }
|
|
||||||
}
|
|
||||||
return { state: 'waiting', message: '等待扫码', status, errorCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 轮询扫码状态(确认后可能还需再轮询一次才下发登录态)
|
|
||||||
* @returns {{ code: 800|801|802|803, message: string, status?: string, errorCode?: number, nickname? }}
|
|
||||||
*/
|
|
||||||
export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
|
||||||
const query = new URLSearchParams(commonParams())
|
|
||||||
const form = new URLSearchParams({
|
|
||||||
need_logo: PASSPORT_FIXED.need_logo,
|
|
||||||
need_short_url: PASSPORT_FIXED.need_short_url,
|
|
||||||
is_frontier: PASSPORT_FIXED.is_frontier,
|
|
||||||
token,
|
|
||||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
|
||||||
next: PASSPORT_FIXED.next,
|
|
||||||
})
|
|
||||||
const request = httpFetch(`${PASSPORT_HOST}/passport/web/check_qrconnect/?${query}`, {
|
|
||||||
method: 'post',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
|
||||||
...webHeaders(qrCookies.get(token) || ''),
|
|
||||||
},
|
|
||||||
body: form.toString(),
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
const { statusCode, headers, body } = await request.promise
|
|
||||||
if (statusCode != 200) throw Object.assign(new Error(`轮询扫码状态失败: HTTP ${statusCode}`), { status: 502 })
|
|
||||||
const data = (body as any)?.data ?? {}
|
|
||||||
const decision = decideQrStatus(data)
|
|
||||||
|
|
||||||
// 采集本次轮询下发的 Set-Cookie(确认后登录态就在这里)
|
|
||||||
let cookie = mergeCookies(qrCookies.get(token) || '', cookieMapFrom(headers))
|
|
||||||
|
|
||||||
// 确认后若响应体带 redirect_url(浏览器里跳这一跳才落 sessionid),服务端补跳一次收 cookie
|
|
||||||
if (decision.state === 'confirmed' && !SESSION_COOKIE_NAMES.some(name => new RegExp(`(?:^|;\\s*)${name}=`).test(cookie))) {
|
|
||||||
const redirectUrl = String(data.redirect_url || '')
|
|
||||||
if (redirectUrl) {
|
|
||||||
try {
|
|
||||||
const hop = await httpFetch(redirectUrl, { method: 'get', headers: webHeaders(cookie), timeout: 15000 }).promise
|
|
||||||
cookie = mergeCookies(cookie, cookieMapFrom(hop.headers))
|
|
||||||
} catch {
|
|
||||||
// 补跳失败不致命:下一轮轮询仍可能下发登录态
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionid = sessionIdOf(cookie)
|
|
||||||
if (sessionid && SESSION_COOKIE_NAMES.some(name => new RegExp(`(?:^|;\\s*)${name}=`).test(cookie))) {
|
|
||||||
setCredentials('qs', { sessionid, cookie })
|
|
||||||
qrCookies.delete(token)
|
|
||||||
const profile = (body as any)?.data?.user_info ?? {}
|
|
||||||
return {
|
|
||||||
code: 803,
|
|
||||||
message: '登录成功',
|
|
||||||
nickname: profile.nickname ?? profile.name ?? undefined,
|
|
||||||
status: decision.status,
|
|
||||||
errorCode: decision.errorCode,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
qrCookies.set(token, cookie)
|
|
||||||
|
|
||||||
if (decision.state === 'confirmed') {
|
|
||||||
return { code: 802, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
|
||||||
}
|
|
||||||
if (decision.state === 'expired' || decision.state === 'failed') {
|
|
||||||
qrCookies.delete(token)
|
|
||||||
return { code: 800, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
|
||||||
}
|
|
||||||
if (decision.state === 'scanned' || decision.state === 'mfa') {
|
|
||||||
return { code: 802, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
|
||||||
}
|
|
||||||
return { code: 801, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 当前登录态 */
|
|
||||||
export const status = (): { logged: boolean; updatedAt?: string } => {
|
|
||||||
const cred = getCredentials('qs') as { sessionid?: string; updatedAt?: string } | null
|
|
||||||
return cred?.sessionid ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 登录态 sessionid(未登录返回空串) */
|
|
||||||
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
|
|
||||||
|
|
||||||
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
|
|
||||||
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
|
|
||||||
|
|
||||||
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus }
|
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
/** 音乐平台标识 */
|
/** 音乐平台标识 */
|
||||||
export type Source =
|
export type Source =
|
||||||
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
|
| 'kw' | 'kg' | 'tx' | 'wy'
|
||||||
| 'mg' | 'bd' | 'joox' | 'bili' | '5sing' | 'apple'
|
| 'mg' | 'bd' | 'joox' | 'bili' | '5sing' | 'apple'
|
||||||
| 'xm' | 'me' | 'qt' | 'lr'
|
| 'xm' | 'me' | 'qt' | 'lr'
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
// 与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口
|
// 与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口
|
||||||
import crypto from 'node:crypto'
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
// 流地址有效期(毫秒),与 qs 流缓存 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
|
// 流地址有效期(毫秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
|
||||||
export const STREAM_SIGN_TTL = 10 * 60_000
|
export const STREAM_SIGN_TTL = 10 * 60_000
|
||||||
|
|
||||||
const hmac = (authKey: string, data: string) =>
|
const hmac = (authKey: string, data: string) =>
|
||||||
crypto.createHmac('sha256', authKey).update(data).digest('hex').slice(0, 32)
|
crypto.createHmac('sha256', authKey).update(data).digest('hex').slice(0, 32)
|
||||||
|
|
||||||
/** 生成流地址的签名参数(path 为不含 query 的路径,如 /stream/qs/123) */
|
/** 生成流地址的签名参数(path 为不含 query 的路径,如 /stream/mg/123) */
|
||||||
export const signStream = (authKey: string, path: string) => {
|
export const signStream = (authKey: string, path: string) => {
|
||||||
const exp = Math.floor((Date.now() + STREAM_SIGN_TTL) / 1000)
|
const exp = Math.floor((Date.now() + STREAM_SIGN_TTL) / 1000)
|
||||||
return `exp=${exp}&sig=${hmac(authKey, `${path}:${exp}`)}`
|
return `exp=${exp}&sig=${hmac(authKey, `${path}:${exp}`)}`
|
||||||
|
|||||||
+24
-46
@@ -1,10 +1,11 @@
|
|||||||
// 登录路由 —— wy 扫码/手机号,kg 扫码/短信/token,tx 扫码/cookie 绑定,凭据持久化于 data/auth.json
|
// 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie,
|
||||||
|
// xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json
|
||||||
|
// (汽水音乐登录已随 qs 平台拆分至独立服务端 SodaServer)
|
||||||
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
|
// 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data }
|
||||||
|
|
||||||
import * as wyAuth from '../sdk/auth/wy.ts'
|
import * as wyAuth from '../sdk/auth/wy.ts'
|
||||||
import * as kgAuth from '../sdk/auth/kg.ts'
|
import * as kgAuth from '../sdk/auth/kg.ts'
|
||||||
import * as txAuth from '../sdk/auth/tx.ts'
|
import * as txAuth from '../sdk/auth/tx.ts'
|
||||||
import * as qsPassport from '../sdk/qs/passport.ts'
|
|
||||||
import * as biliAuth from '../sdk/bili/login.ts'
|
import * as biliAuth from '../sdk/bili/login.ts'
|
||||||
import { status as xmStatus } from '../sdk/xm/index.ts'
|
import { status as xmStatus } from '../sdk/xm/index.ts'
|
||||||
import { status as meStatus } from '../sdk/me/index.ts'
|
import { status as meStatus } from '../sdk/me/index.ts'
|
||||||
@@ -75,6 +76,26 @@ export const loginRoutes = async(fastify) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
fastify.post('/login/wy', {
|
||||||
|
schema: {
|
||||||
|
summary: '绑定网易云 cookie',
|
||||||
|
description: 'cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。' +
|
||||||
|
'绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。',
|
||||||
|
tags: ['登录'],
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
properties: { cookie: { type: 'string' } },
|
||||||
|
required: ['cookie'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, async(request, reply) => {
|
||||||
|
try {
|
||||||
|
return await wyAuth.loginByCookie(request.body.cookie)
|
||||||
|
} catch (err) {
|
||||||
|
return fail(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
fastify.delete('/login/wy', {
|
fastify.delete('/login/wy', {
|
||||||
schema: { summary: '退出网易云登录', tags: ['登录'] },
|
schema: { summary: '退出网易云登录', tags: ['登录'] },
|
||||||
}, async() => {
|
}, async() => {
|
||||||
@@ -288,48 +309,6 @@ export const loginRoutes = async(fastify) => {
|
|||||||
return { logged: false }
|
return { logged: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------- 汽水音乐 ----------
|
|
||||||
|
|
||||||
fastify.post('/login/qs/qr', {
|
|
||||||
schema: {
|
|
||||||
summary: '获取汽水音乐扫码登录二维码',
|
|
||||||
description: '用「汽水音乐 APP」扫一扫(官方 PC 客户端同款文案:打开汽水音乐 APP → 搜索框右侧扫一扫;' +
|
|
||||||
'抖音 APP 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' +
|
|
||||||
'返回 qrimg(data URL)可直接展示,token 需原样回传;二维码内容为官方 PC 客户端同款扫码页' +
|
|
||||||
'(bff-pc.qishui.com/light/invoke/scan_login,另见 scanUrl 字段)。轮询间隔建议 ≥2 秒。' +
|
|
||||||
'登录后 qs 取流走签名 track_v2,解锁完整曲目。',
|
|
||||||
tags: ['登录'],
|
|
||||||
},
|
|
||||||
}, async(_request, reply) => {
|
|
||||||
try {
|
|
||||||
return await qsPassport.createQr()
|
|
||||||
} catch (err) {
|
|
||||||
return fail(reply, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.post('/login/qs/qr/check', {
|
|
||||||
schema: {
|
|
||||||
summary: '轮询汽水音乐扫码状态',
|
|
||||||
description: 'code:800 二维码过期/被拒 / 801 等待扫码 / 802 已扫码待确认(含需二次验证)/ 803 登录成功(自动保存凭据)。',
|
|
||||||
tags: ['登录'],
|
|
||||||
body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] },
|
|
||||||
},
|
|
||||||
}, async(request, reply) => {
|
|
||||||
try {
|
|
||||||
return await qsPassport.checkQr(request.body.token)
|
|
||||||
} catch (err) {
|
|
||||||
return fail(reply, err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
fastify.delete('/login/qs', {
|
|
||||||
schema: { summary: '退出汽水音乐登录', tags: ['登录'] },
|
|
||||||
}, async() => {
|
|
||||||
clearCredentials('qs')
|
|
||||||
return { logged: false }
|
|
||||||
})
|
|
||||||
|
|
||||||
// ---------- 喜马拉雅 ----------
|
// ---------- 喜马拉雅 ----------
|
||||||
|
|
||||||
fastify.post('/login/xm', {
|
fastify.post('/login/xm', {
|
||||||
@@ -477,7 +456,7 @@ export const loginRoutes = async(fastify) => {
|
|||||||
schema: {
|
schema: {
|
||||||
summary: '查看登录状态',
|
summary: '查看登录状态',
|
||||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' +
|
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' +
|
||||||
'qs/xm/me/lr/bd/joox/apple/5sing 为已存凭据。',
|
'xm/me/lr/bd/joox/apple/5sing 为已存凭据。',
|
||||||
tags: ['登录'],
|
tags: ['登录'],
|
||||||
},
|
},
|
||||||
}, async() => {
|
}, async() => {
|
||||||
@@ -489,7 +468,6 @@ export const loginRoutes = async(fastify) => {
|
|||||||
wy: wy ? { logged: true, ...wy } : { logged: false },
|
wy: wy ? { logged: true, ...wy } : { logged: false },
|
||||||
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
|
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
|
||||||
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
|
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
|
||||||
qs: qsPassport.status(),
|
|
||||||
xm: xmStatus(),
|
xm: xmStatus(),
|
||||||
me: meStatus(),
|
me: meStatus(),
|
||||||
lr: lrAuth.status(),
|
lr: lrAuth.status(),
|
||||||
|
|||||||
+10
-26
@@ -183,7 +183,7 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
if (res.encrypted && res.fileKey) {
|
if (res.encrypted && res.fileKey) {
|
||||||
rawUrl = `/stream/mg/${registerMiguStream(rawUrl, String(res.fileKey))}`
|
rawUrl = `/stream/mg/${registerMiguStream(rawUrl, String(res.fileKey))}`
|
||||||
}
|
}
|
||||||
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
|
// mg 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
|
||||||
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
|
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
|
||||||
let url = rawUrl
|
let url = rawUrl
|
||||||
if (url.startsWith('/')) {
|
if (url.startsWith('/')) {
|
||||||
@@ -240,7 +240,7 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
fastify.get('/url/:source/:id', {
|
fastify.get('/url/:source/:id', {
|
||||||
schema: {
|
schema: {
|
||||||
summary: '获取播放地址',
|
summary: '获取播放地址',
|
||||||
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs/xm/me 用声音 id,qt 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。' +
|
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,xm/me 用声音 id,qt 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。' +
|
||||||
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
|
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
|
||||||
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
|
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
|
||||||
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
|
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
|
||||||
@@ -260,41 +260,25 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
},
|
},
|
||||||
}, urlHandler)
|
}, urlHandler)
|
||||||
|
|
||||||
// 加密流解密代理(qs 汽水解密流 / mg 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。
|
// 加密流解密代理(mg 咪咕 Z3D/I3D 加密流):/url/mg 命中加密候选时登记票据并返回
|
||||||
// - qs:/url/qs 登录态取流返回相对地址 /stream/qs/<trackId>,本端点完成"签名 track_v2 →
|
// /stream/mg/<票据>,本端点凭票下载密流逐字节解密;由 /url 产出相对地址,路由层负责补全 origin;
|
||||||
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"
|
// raw 豁免 { code, data } 信封包装(汽水解密流已拆分至独立服务端 SodaServer)
|
||||||
// - mg:/url/mg 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/mg/<票据>,本端点凭票下载密流逐字节解密
|
|
||||||
// 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装
|
|
||||||
fastify.get('/stream/:source/:id', {
|
fastify.get('/stream/:source/:id', {
|
||||||
config: { raw: true },
|
config: { raw: true },
|
||||||
schema: {
|
schema: {
|
||||||
summary: '加密流解密代理(qs / mg)',
|
summary: '加密流解密代理(mg)',
|
||||||
description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/mg 命中加密流时的地址),' +
|
description: '取流响应中返回的相对地址(/url/mg 命中加密流时的地址),' +
|
||||||
'服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' +
|
'服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' +
|
||||||
'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' +
|
'id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。',
|
||||||
'source=mg 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。',
|
|
||||||
tags: ['歌曲资源'],
|
tags: ['歌曲资源'],
|
||||||
params: params({
|
params: params({
|
||||||
source: { type: 'string', enum: ['qs', 'mg'], description: '平台 id:qs=汽水音乐解密流 / mg=咪咕加密流', example: 'qs' },
|
source: { type: 'string', enum: ['mg'], description: '平台 id:mg=咪咕加密流', example: 'mg' },
|
||||||
id: { type: 'string', description: 'qs: 汽水 track id;mg: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
|
id: { type: 'string', description: '取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
}, async(request, reply) => {
|
}, async(request, reply) => {
|
||||||
const { source, id } = request.params
|
const { source, id } = request.params
|
||||||
|
|
||||||
// 汽水:track id → 签名请求 → 下载密音频 → 本地解密为明文
|
|
||||||
if (source === 'qs') {
|
|
||||||
try {
|
|
||||||
const entry = await fastify.musicSdk.qs.getStreamBuffer(String(id))
|
|
||||||
reply.type(entry.contentType || 'audio/mp4')
|
|
||||||
reply.header('Accept-Ranges', 'none')
|
|
||||||
reply.header('Content-Length', entry.buffer.length)
|
|
||||||
return reply.send(entry.buffer)
|
|
||||||
} catch (err: any) {
|
|
||||||
throw upstream(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 咪咕:票据 → 逐块解密流
|
// 咪咕:票据 → 逐块解密流
|
||||||
const ticket = takeMiguStream(String(id))
|
const ticket = takeMiguStream(String(id))
|
||||||
if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/mg 取流)')
|
if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/mg 取流)')
|
||||||
|
|||||||
+4
-4
@@ -20,7 +20,7 @@
|
|||||||
// GET /playlist/:source/categories[/:categoryId] 歌单分类
|
// GET /playlist/:source/categories[/:categoryId] 歌单分类
|
||||||
// POST /parse/{song|album|playlist}/:source 链接解析
|
// POST /parse/{song|album|playlist}/:source 链接解析
|
||||||
// GET /user-playlist/:source 个人歌单
|
// GET /user-playlist/:source 个人歌单
|
||||||
// GET /stream/:source/:id 解密流代理(source: qs 汽水 / mg 咪咕)
|
// GET /stream/:source/:id 解密流代理(source: mg 咪咕)
|
||||||
// GET /sources /health GET /docs /redoc /openapi.json
|
// GET /sources /health GET /docs /redoc /openapi.json
|
||||||
//
|
//
|
||||||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||||||
@@ -48,8 +48,8 @@ const PORT = process.env.PORT || 16688
|
|||||||
const DOCS_PREFIX = '/docs'
|
const DOCS_PREFIX = '/docs'
|
||||||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
|
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
|
||||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' +
|
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)/ bd(千千)/ ' +
|
||||||
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。'
|
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。汽水音乐已拆分至独立服务端 SodaServer。'
|
||||||
|
|
||||||
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'))
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ fastify.register(swagger, {
|
|||||||
{ name: '榜单', description: '排行榜' },
|
{ name: '榜单', description: '排行榜' },
|
||||||
{ name: '歌单', description: '歌单与热搜' },
|
{ name: '歌单', description: '歌单与热搜' },
|
||||||
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
|
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
|
||||||
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie、qs 抖音扫码)与状态' },
|
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie)与状态' },
|
||||||
{ name: '系统', description: '健康检查、平台信息、文档' },
|
{ name: '系统', description: '健康检查、平台信息、文档' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// bili 取流 CDN 分级选址回归 —— SDK 层直调(不依赖服务器实例,含真实 B 站上游请求)
|
||||||
|
// 运行:node test/bili-cdn-test.ts(已并入 pnpm test)
|
||||||
|
//
|
||||||
|
// 覆盖:getMusicUrl 返回地址不落在 PCDN(mcdn 无线宝/mountaintoys 迅雷/szbdyd/nexusedgeio),
|
||||||
|
// parse 路径填充的 url 同样校验。
|
||||||
|
// 判定:命中 PCDN 即失败——fail-soft 兜底意味着 upos/cn 候选 20s 探测全败,是真实的选址退化信号
|
||||||
|
// (smoke 的「URL bili」项走 HTTP 层、只软警告;本文件是 SDK 层的硬断言)。
|
||||||
|
|
||||||
|
import biliApi from '../sdk/apis/bili.ts'
|
||||||
|
import { parseSong } from '../sdk/bili/parse.ts'
|
||||||
|
|
||||||
|
/** 与 sdk/apis/bili.ts 的 hostRank 同构:0=正规 CDN 1=B站系 2=PCDN */
|
||||||
|
const hostRank = (url: string): number => {
|
||||||
|
let host = ''
|
||||||
|
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
|
||||||
|
if (host.endsWith('.akamaized.net')) return 0
|
||||||
|
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
const LABEL = ['正规CDN', 'B站系', 'PCDN(坏)']
|
||||||
|
|
||||||
|
const results: { name: string; ok: boolean; detail?: any }[] = []
|
||||||
|
const test = async(name: string, fn: () => Promise<any>) => {
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
results.push({ name, ok: true, detail: result })
|
||||||
|
console.log(`✅ ${name}${result ? `: ${result}` : ''}`)
|
||||||
|
} catch (err: any) {
|
||||||
|
results.push({ name, ok: false, detail: err.message })
|
||||||
|
console.log(`❌ ${name}: ${err.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 固定样本(2026-10-04 实测均有多分P候选与 upos 备源);换样本时优先选多分P、热度中等的稿件
|
||||||
|
const CASES = ['BV1BZbSzZEGT', 'BV1GJ411x7h7', 'BV1d4411N7zD', 'BV1EC41147U1', 'BV19F5k6VEEp']
|
||||||
|
|
||||||
|
for (const bvid of CASES) {
|
||||||
|
await test(`取流 ${bvid} 落正规 CDN`, async() => {
|
||||||
|
const { promise } = biliApi.getMusicUrl({ songmid: bvid }, '128k')
|
||||||
|
const res = await promise
|
||||||
|
const rank = hostRank(res.url)
|
||||||
|
if (rank === 2) throw new Error(`命中 PCDN:${new URL(res.url).host}(backupUrl 探测疑似全败)`)
|
||||||
|
return `${LABEL[rank]} ${new URL(res.url).host}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await test('parse 填充 url 落正规 CDN', async() => {
|
||||||
|
const song = await parseSong('https://www.bilibili.com/video/BV1GJ411x7h7')
|
||||||
|
if (!song.url) throw new Error('parse 未填充 url')
|
||||||
|
const rank = hostRank(String(song.url))
|
||||||
|
if (rank === 2) throw new Error(`命中 PCDN:${new URL(String(song.url)).host}`)
|
||||||
|
return `${LABEL[rank]} ${new URL(String(song.url)).host}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const failed = results.filter(r => !r.ok).length
|
||||||
|
console.log(`\n===== 结果: ${results.length - failed}/${results.length} 通过 =====`)
|
||||||
|
process.exit(failed ? 1 : 0)
|
||||||
+35
-37
@@ -156,7 +156,7 @@ if (externalServer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
for (const source of ['kw', 'kg', 'tx', 'wy', 'qs']) {
|
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
||||||
await test(`搜索 ${source}`, async() => {
|
await test(`搜索 ${source}`, async() => {
|
||||||
const r = await get(`/search/${source}/${encodeURIComponent('稻香')}?limit=2`)
|
const r = await get(`/search/${source}/${encodeURIComponent('稻香')}?limit=2`)
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
@@ -221,13 +221,6 @@ await test('URL tx 320k', async() => {
|
|||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
// qs 匿名只有 30 秒试听(7079108541549643812 = 陈粒《小半》,2026-10-01 实测可用)
|
|
||||||
await test('URL qs 试听 128k', async() => {
|
|
||||||
const r = await get('/url/qs/7079108541549643812?quality=128k')
|
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
|
||||||
if (!r.data?.url) throw new Error('无 url')
|
|
||||||
return r.data.url.slice(0, 60)
|
|
||||||
})
|
|
||||||
|
|
||||||
// xm 匿名免费曲直链(12096669 = 苏荷酒吧我爱的人,免费,2026-10-02 实测可用)
|
// xm 匿名免费曲直链(12096669 = 苏荷酒吧我爱的人,免费,2026-10-02 实测可用)
|
||||||
await test('URL xm 免费曲', async() => {
|
await test('URL xm 免费曲', async() => {
|
||||||
@@ -338,12 +331,6 @@ await test('歌词 kw', async() => {
|
|||||||
return `${r.data.lyric.slice(0, 30)}...`
|
return `${r.data.lyric.slice(0, 30)}...`
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('歌词 qs', async() => {
|
|
||||||
const r = await get('/lyric/qs/7079108541549643812')
|
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
|
||||||
if (!r.data?.lyric) throw new Error('无歌词')
|
|
||||||
return `${r.data.lyric.slice(0, 30)}...`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 封面
|
// 封面
|
||||||
await test('封面 kw', async() => {
|
await test('封面 kw', async() => {
|
||||||
@@ -353,12 +340,6 @@ await test('封面 kw', async() => {
|
|||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('封面 qs', async() => {
|
|
||||||
const r = await get('/pic/qs/7079108541549643812')
|
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
|
||||||
if (!r.data?.url) throw new Error('无 url')
|
|
||||||
return r.data.url.slice(0, 60)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 榜单 & 热搜
|
// 榜单 & 热搜
|
||||||
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
||||||
@@ -555,6 +536,17 @@ await test('URL 5sing(档位 sq/hq/lq)', async() => {
|
|||||||
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test('URL bili(CDN 分级选址)', async() => {
|
||||||
|
const r = await get(`/search/bili/${encodeURIComponent('周杰伦')}?limit=3`)
|
||||||
|
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||||
|
const u = await get(`/url/bili/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`)
|
||||||
|
if (u.code != 0) throw new Error(u.msg)
|
||||||
|
// 返回地址应落在正规 CDN(upos-*.bilivideo.com 等);命中 PCDN 说明备源探测全挂,软警告不判失败
|
||||||
|
const host = new URL(String(u.data.url)).hostname
|
||||||
|
const pcdn = /\.(mcdn\.bilivideo\.cn|mountaintoys\.cn|szbdyd\.com|nexusedgeio\.com)$/.test(host)
|
||||||
|
return `实际 ${u.data.type},${host}${pcdn ? '(警告:命中 PCDN,请检查 backupUrl 探测)' : ''}`
|
||||||
|
})
|
||||||
|
|
||||||
await test('歌词 mg', async() => {
|
await test('歌词 mg', async() => {
|
||||||
const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
|
const r = await get(`/search/mg/${encodeURIComponent('起风了')}?limit=3`)
|
||||||
const song = r.data?.list?.[0]
|
const song = r.data?.list?.[0]
|
||||||
@@ -667,12 +659,12 @@ await test('链接解析 /parse/album/apple', async() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
await test('链接解析 不支持的平台 → 404', async() => {
|
await test('链接解析 不支持的平台 → 404', async() => {
|
||||||
// qs 无 parse 实现(以 /sources 的 parse 能力位为准)。
|
// xm 无 parse 实现(以 /sources 的 parse 能力位为准)。
|
||||||
// 原先此处断言 kw 返回 404 —— 但 kw 的 parse 早已实现(sdk/kw/parse.ts),
|
// 原先此处断言 kw 返回 404 —— 但 kw 的 parse 早已实现(sdk/kw/parse.ts),
|
||||||
// 该断言过期后恒失败,导致 pnpm test 永远无法全绿。
|
// 该断言过期后恒失败,导致 pnpm test 永远无法全绿。
|
||||||
const r = await postJson('/parse/song/qs', { link: 'https://music.douyin.com/qishui/share/track?track_id=1' })
|
const r = await postJson('/parse/song/xm', { link: 'https://www.ximalaya.com/album/1' })
|
||||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||||
return 'qs 未实现 parse(按上游)'
|
return 'xm 未实现 parse(按上游)'
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('链接解析 /parse/song/kw(正向)', async() => {
|
await test('链接解析 /parse/song/kw(正向)', async() => {
|
||||||
@@ -821,15 +813,21 @@ await test('扫码二维码创建 POST /login/wy/qr', async() => {
|
|||||||
return r.body.data.key.slice(0, 18) + '…'
|
return r.body.data.key.slice(0, 18) + '…'
|
||||||
})
|
})
|
||||||
|
|
||||||
await test('扫码二维码创建 POST /login/qs/qr', async() => {
|
// wy cookie 绑定校验(缺 MUSIC_U 直接 422,不打上游)
|
||||||
const r = await postJson('/login/qs/qr', {})
|
await test('wy 坏 cookie 拒绝 POST /login/wy', async() => {
|
||||||
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
|
const r = await postJson('/login/wy', { cookie: 'foo=bar; baz=1' })
|
||||||
if (!r.body.data?.token || !r.body.data?.qrimg?.startsWith('data:image/')) throw new Error('响应不完整')
|
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||||||
// 二维码必须是官方 PC 客户端同款扫码页(旧的 ucenter_web/app/sdk-next 已 404,抖音扫出「无法访问页面」)
|
return `status ${r.status}`
|
||||||
if (!String(r.body.data?.scanUrl ?? '').includes('/light/invoke/scan_login')) throw new Error(`二维码内容非官方扫码页: ${r.body.data?.scanUrl}`)
|
|
||||||
return `scanApp=${r.body.data.scanApp || '?'}`
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// wy 失效 MUSIC_U 会被 account/get 实测拒绝(401,与 kg 假 token 同款语义)
|
||||||
|
await test('wy 假 cookie 拒绝 POST /login/wy', async() => {
|
||||||
|
const r = await postJson('/login/wy', { cookie: 'MUSIC_U=smoke_fake_music_u' })
|
||||||
|
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
||||||
|
return r.body?.msg
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
||||||
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
||||||
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
||||||
@@ -900,23 +898,23 @@ if (!externalServer && !process.env.AUTH_KEY) {
|
|||||||
// /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃
|
// /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃
|
||||||
await test('AUTH_KEY 流代理签名机制', async() => {
|
await test('AUTH_KEY 流代理签名机制', async() => {
|
||||||
// 裸秘钥 ?key= 已不生效
|
// 裸秘钥 ?key= 已不生效
|
||||||
const rawKey = await authFetch(`/stream/qs/123?key=${AUTH_TEST_KEY}`)
|
const rawKey = await authFetch(`/stream/mg/123?key=${AUTH_TEST_KEY}`)
|
||||||
if (rawKey.status !== 401) throw new Error(`裸 ?key= 应 401,实际 ${rawKey.status}`)
|
if (rawKey.status !== 401) throw new Error(`裸 ?key= 应 401,实际 ${rawKey.status}`)
|
||||||
// X-Server-Key 头可通过鉴权(后续业务可能 502,但不能是 401)
|
// X-Server-Key 头可通过鉴权(后续业务可能 502,但不能是 401)
|
||||||
const withHeader = await authFetch('/stream/qs/123', { headers: keyHeaders })
|
const withHeader = await authFetch('/stream/mg/123', { headers: keyHeaders })
|
||||||
if (withHeader.status === 401) throw new Error('X-Server-Key 应通过流代理鉴权')
|
if (withHeader.status === 401) throw new Error('X-Server-Key 应通过流代理鉴权')
|
||||||
// 正确签名通过;错签名 401
|
// 正确签名通过;错签名 401
|
||||||
const exp = Math.floor(Date.now() / 1000) + 600
|
const exp = Math.floor(Date.now() / 1000) + 600
|
||||||
const crypto = await import('node:crypto')
|
const crypto = await import('node:crypto')
|
||||||
const sig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/qs/123:${exp}`).digest('hex').slice(0, 32)
|
const sig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/mg/123:${exp}`).digest('hex').slice(0, 32)
|
||||||
const signed = await authFetch(`/stream/qs/123?exp=${exp}&sig=${sig}`)
|
const signed = await authFetch(`/stream/mg/123?exp=${exp}&sig=${sig}`)
|
||||||
if (signed.status === 401) throw new Error('有效签名应通过流代理鉴权')
|
if (signed.status === 401) throw new Error('有效签名应通过流代理鉴权')
|
||||||
const badSig = await authFetch(`/stream/qs/123?exp=${exp}&sig=${'0'.repeat(32)}`)
|
const badSig = await authFetch(`/stream/mg/123?exp=${exp}&sig=${'0'.repeat(32)}`)
|
||||||
if (badSig.status !== 401) throw new Error(`错签名应 401,实际 ${badSig.status}`)
|
if (badSig.status !== 401) throw new Error(`错签名应 401,实际 ${badSig.status}`)
|
||||||
// 过期签名 401
|
// 过期签名 401
|
||||||
const oldExp = Math.floor(Date.now() / 1000) - 10
|
const oldExp = Math.floor(Date.now() / 1000) - 10
|
||||||
const oldSig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/qs/123:${oldExp}`).digest('hex').slice(0, 32)
|
const oldSig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/mg/123:${oldExp}`).digest('hex').slice(0, 32)
|
||||||
const expired = await authFetch(`/stream/qs/123?exp=${oldExp}&sig=${oldSig}`)
|
const expired = await authFetch(`/stream/mg/123?exp=${oldExp}&sig=${oldSig}`)
|
||||||
if (expired.status !== 401) throw new Error(`过期签名应 401,实际 ${expired.status}`)
|
if (expired.status !== 401) throw new Error(`过期签名应 401,实际 ${expired.status}`)
|
||||||
return '头/签名通过,裸key/错签/过期签均 401'
|
return '头/签名通过,裸key/错签/过期签均 401'
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user