From 800871ef6e2bab11265eb184fb5358eefd3fb883 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:08:04 +0800 Subject: [PATCH] =?UTF-8?q?refactor(qs)!:=20=E6=B1=BD=E6=B0=B4=E6=8B=86?= =?UTF-8?q?=E5=88=86=E4=B8=BA=E7=8B=AC=E7=AB=8B=E6=9C=8D=E5=8A=A1=E7=AB=AF?= =?UTF-8?q?=20SodaServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: /search/qs、/url/qs、/stream/qs、/login/qs/* 不再由本服务提供, 改由独立服务端 SodaServer(gitea.4i.hk/hhhge/SodaServer)承载,API 路径兼容。 - 删除 sdk/qs/(7 文件)与 sdk/apis/qs.ts,平台数 15 → 14 - /stream 路由收窄为 mg 咪咕加密流单平台 - /login 与 /login/status 移除 qs;README/docs 同步清理,汽水专节改指 SodaServer - smoke 测试移除 qs 用例(流签名样本路径改 /stream/mg/123) --- CODE-AUDIT.md | 1 + README.md | 38 ++-- docs/API.md | 8 +- sdk/apis/qs.ts | 189 -------------------- sdk/index.ts | 4 - sdk/qs/audiodecrypt.ts | 385 ---------------------------------------- sdk/qs/index.ts | 31 ---- sdk/qs/lyric.ts | 46 ----- sdk/qs/mssdk.ts | 222 ----------------------- sdk/qs/musicInfo.ts | 31 ---- sdk/qs/musicSearch.ts | 91 ---------- sdk/qs/passport.ts | 369 -------------------------------------- sdk/types.ts | 2 +- src/http/stream-sign.ts | 4 +- src/routes-login.ts | 110 +----------- src/routes.ts | 36 ++-- src/server.ts | 8 +- test/smoke-test.ts | 84 ++------- 18 files changed, 47 insertions(+), 1612 deletions(-) delete mode 100644 sdk/apis/qs.ts delete mode 100644 sdk/qs/audiodecrypt.ts delete mode 100644 sdk/qs/index.ts delete mode 100644 sdk/qs/lyric.ts delete mode 100644 sdk/qs/mssdk.ts delete mode 100644 sdk/qs/musicInfo.ts delete mode 100644 sdk/qs/musicSearch.ts delete mode 100644 sdk/qs/passport.ts diff --git a/CODE-AUDIT.md b/CODE-AUDIT.md index e90a177..309fd93 100644 --- a/CODE-AUDIT.md +++ b/CODE-AUDIT.md @@ -1,6 +1,7 @@ # MusicServer 全量代码检查报告 **检查时间**: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 原生构建); 第四节 P2 卫生项已于 2026-10-03 清理完毕(注释死代码 630+ 行、裸 `console.log` 吞错 6 处、未使用符号 26 处清零、bili 状态校验去重); 第八节线索逐条复核:qian 导出名、migu 异常消息、Quality `preview`、bili 重试重复均已过时(早前重写时已解决),kw/kg/tx/wy parse/playlist 仅有骨架相似、实质各异,评估后不抽公共层。 diff --git a/README.md b/README.md index 853baee..179a8d3 100644 --- a/README.md +++ b/README.md @@ -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 服务) - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接) - 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查 @@ -17,7 +17,6 @@ | 酷狗音乐 | `kg` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/短信/账密/token | | QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie | | 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 | -| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 | | 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) | | 千千音乐 | `bd` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 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 /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 /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`mg`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 | +| `GET /stream/:source/:id` | 加密流解密代理(`mg` 咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 | | `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | | `GET /pic/:source/:id` | 获取封面 | | `GET /boards/:source` | 榜单列表 | @@ -79,12 +78,11 @@ pnpm typecheck # 仅类型检查,不运行 | `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | | `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) | | `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/me` | 绑定猫耳FM token(App 抓包或 m.missevan.com 复制,须含 `token=`,解锁付费声音取流) | | `POST /login/lr` | 懒人听书账号登录(account+password,懒人取流要求登录态,免费章节同样) | | `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 /sources` | 平台列表(音质与能力) | | `GET /health` | 健康检查 | @@ -98,7 +96,6 @@ pnpm typecheck # 仅类型检查,不运行 - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **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 后取流 - **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流 - **qt**:`:` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目 @@ -129,22 +126,13 @@ X-Server-Key: # 统一验证头(全部路径) per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。 -## 汽水 PC 签名(可选增强) +## 汽水音乐(已拆分) -汽水(`qs`)的 PC 通道(搜索优先走、取流降级走)依赖 `X-Helios`/`X-Medusa` 两个加密签名头,按优先级两种供给方式;都没有时 PC 通道自动关闭,只走安卓自签通道(匿名搜索/试听不受影响): - -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`)。固定值时效自负,被风控时搜索/取流自动回落安卓通道。 +汽水音乐(`qs`)已拆分为独立服务端 **SodaServer**(`gitea.4i.hk/hhhge/SodaServer`,本地与 MusicServer 同级):API 路径兼容(`/search/qs`、`/url/qs`、`/stream/qs`、`/login/qs/*`),凭据文件格式同源可互迁;PC 签名继续复用 qs-signer 部署。 ## 外部音源 -设置 `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 白名单(调试用)。 @@ -171,7 +159,7 @@ export async function getMusicUrl({ source, id, songInfo, quality, signal }) { src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) server.ts HTTP 服务器入口(pnpm start 实际启动文件) 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 账密) source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK) source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) @@ -185,18 +173,17 @@ src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源 openapi/ components.ts OpenAPI 共享 schema(与路由校验同源) ui.ts ReDoc 页面模板 -sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;15 个平台) +sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;14 个平台) 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/续期) common/ SDK 基础设施 request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等) message.ts 请求错误消息 - kg/ kw/ tx/ wy/ qs/ migu/ qian/ joox/ bili/ 5sing/ apple/ xm/ me/ qt/ lr/ - 平台模块(搜索/歌词/榜单/歌单/专辑/链接解析等;qs 含字节系参数签名与音频解密 crypto/, - migu 含 Z3D 流解密,bili 含扫码登录) - apis/ kw.ts kg.ts tx.ts wy.ts qs.ts migu.ts qian.ts joox.ts bili.ts 5sing.ts apple.ts … + kg/ kw/ tx/ wy/ migu/ qian/ joox/ bili/ 5sing/ apple/ xm/ me/ qt/ lr/ + 平台模块(搜索/歌词/榜单/歌单/专辑/链接解析等;migu 含 Z3D 流解密,bili 含扫码登录) + apis/ kw.ts kg.ts tx.ts wy.ts migu.ts qian.ts joox.ts bili.ts 5sing.ts apple.ts … test/ smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口) source-simple-test.ts 简单 API 音源脚本加载器测试(离线) @@ -208,7 +195,6 @@ test/ - **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。 - **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 返回「登录后免费试听」时自动降级到可用的最高档。 - **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。 - **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。 diff --git a/docs/API.md b/docs/API.md index 6d0b587..72c00c4 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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,仅试听), 以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。 @@ -39,7 +39,7 @@ ### 歌曲 id 形态 -各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`mg` 的三元组、 +各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`xm`/`me` 的 id、`mg` 的三元组、 `qt`/`lr` 的复合 id、`bili` 的 `|` 等)详见 README「歌曲 ID 约定」。 复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。 @@ -66,7 +66,7 @@ | 方法 | 路径 | 说明 | | --- | --- | --- | | 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 | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) | @@ -119,8 +119,6 @@ | POST | `/login/tx/qr` → `/login/tx/qr/check` | QQ 音乐扫码 | | POST | `/login/tx` | 绑定 QQ 音乐 cookie(可选 App 抓包字段供自动续期) | | DELETE | `/login/tx` | 退出 QQ 音乐 | -| POST | `/login/qs/qr` → `/login/qs/qr/check` | 汽水音乐扫码(用**汽水音乐 App** 扫一扫) | -| DELETE | `/login/qs` | 退出汽水音乐 | | POST | `/login/xm` · `/login/me` | 绑定喜马拉雅 cookie / 猫耳FM token | | DELETE | `/login/xm` · `/login/me` | 退出对应平台 | | POST | `/login/lr` | 懒人听书账号登录(`account` + `password`) | diff --git a/sdk/apis/qs.ts b/sdk/apis/qs.ts deleted file mode 100644 index 0043364..0000000 --- a/sdk/apis/qs.ts +++ /dev/null @@ -1,189 +0,0 @@ -// 汽水音乐 URL 获取(直连) -// 两条取流通道: -// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k) -// 登录态: 官方 PC 签名 X-Helios/X-Medusa(见 sdk/qs/mssdk.ts)→ /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 { getCookie, getSessionId } from '../qs/passport.ts' -import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts' -import { decryptAudioBuffer, resolveAudioKey } from '../qs/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() -const inflight = new Map>() - -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 => { - 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 -} - -// 登录态通道:官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts) -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, 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' }) - -const fetchTrackPlayerByPc = async (trackId: string) => { - const sessionId = getSessionId() - if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)') - const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}` - const requestBody = TRACK_V2_BODY(trackId) - const headers: Record = { - '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 resolveSignedStream = async (trackId: string): Promise => { - const trackPlayer = await fetchTrackPlayerByPc(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 => { - 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 } diff --git a/sdk/index.ts b/sdk/index.ts index 810d5d9..e06e04b 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -2,7 +2,6 @@ import kw from './kw/index.ts' import kg from './kg/index.ts' import tx from './tx/index.ts' import wy from './wy/index.ts' -import qs from './qs/index.ts' import xm from './xm/index.ts' import me from './me/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 txQualitys } from './apis/tx.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 meQualitys } from './apis/me.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: 'tx', name: 'QQ音乐', module: tx }, { id: 'wy', name: '网易音乐', module: wy }, - { id: 'qs', name: '汽水音乐', module: qs }, { id: 'xm', name: '喜马拉雅', module: xm, noAggregate: true }, { id: 'me', name: '猫耳FM', module: me, noAggregate: true }, { id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true }, @@ -76,7 +73,6 @@ export const supportQuality: Record = { kg: kgQualitys, tx: txQualitys, wy: wyQualitys, - qs: qsQualitys, xm: xmQualitys, me: meQualitys, qt: qtQualitys, diff --git a/sdk/qs/audiodecrypt.ts b/sdk/qs/audiodecrypt.ts deleted file mode 100644 index 0031509..0000000 --- a/sdk/qs/audiodecrypt.ts +++ /dev/null @@ -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, -} \ No newline at end of file diff --git a/sdk/qs/index.ts b/sdk/qs/index.ts deleted file mode 100644 index 2d0a478..0000000 --- a/sdk/qs/index.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 汽水音乐(字节跳动)平台模块 -// 匿名可用面(2026-10-01 实测):曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。 -// 搜索与登录态取流需 PC 签名(QS_SIGN_SERVER 或固定值,见 sdk/qs/mssdk.ts 与 qs-signer 仓库): -// 登录态(/login/qs/qr 扫码)走 X-Helios/X-Medusa 签名 /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 diff --git a/sdk/qs/lyric.ts b/sdk/qs/lyric.ts deleted file mode 100644 index f70533b..0000000 --- a/sdk/qs/lyric.ts +++ /dev/null @@ -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 -} diff --git a/sdk/qs/mssdk.ts b/sdk/qs/mssdk.ts deleted file mode 100644 index dea0d51..0000000 --- a/sdk/qs/mssdk.ts +++ /dev/null @@ -1,222 +0,0 @@ -// 汽水 PC 通道签名(X-Helios / X-Medusa)—— 三种供给方式,按优先级: -// 1) 签名服务器(显式):环境变量 QS_SIGN_SERVER 指向签名服务(如 http://127.0.0.1:16889, -// 可选 QS_SIGN_TOKEN 配 Bearer 鉴权),HTTP POST /sign。 -// 契约兼容独立仓库 qs-signer(gitea.4i.hk/hhhge/qs-signer,本地与 MusicServer 同级) -// 的 mssdk-sign 服务(请求 {url, headers, body?},响应 {ok, headers, added?})。 -// 2) 签名服务器(自动探测):QS_SIGN_SERVER 未配置时,进程启动探测本机 16689 端口—— -// GET /healthz 应答 ok:true 且 service:"mssdk-sign" 才认定为汽水签名服务并自动接入 -// (16689 上是别的服务则忽略,端口没人听静默跳过,均回落到 3)。 -// 3) 固定值兜底:无签名服务器时,两个加密头直接用抓包固定值 —— -// 环境变量 QS_HELIOS / QS_MEDUSA,或 data/qs-sign.json: -// { "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" } -// 签名与设备/URL 绑定:固定值必须搭配抓包时的同一套 device_id/install_id -// (json 里带上即自动对齐 URL 参数),时效自负。 -// 两者都没有:PC 签名不可用 —— 搜索/取流走 track_v2 的通道直接报错(匿名试听不受影响)。 -// 官方只对 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 './passport.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 - -// —— 端口探测:QS_SIGN_SERVER 未配置时,启动探测本机 16689 是不是汽水签名服务 —— -const PROBE_BASE = 'http://127.0.0.1:16689' -const PROBE_TIMEOUT = 2000 - -// undefined=探测未完成;null=探测过、不是签名服务;字符串=探测到的签名服务地址 -let probedServer: string | null | undefined -let probing: Promise | null = null - -/** healthz 应答 ok:true 且 service:"mssdk-sign" 才认——16689 上可能是无关服务 */ -const isSignService = (json: any): boolean => json?.ok === true && json?.service === 'mssdk-sign' - -const probeOnce = async (): Promise => { - try { - const res = await fetch(`${PROBE_BASE}/healthz`, { signal: AbortSignal.timeout(PROBE_TIMEOUT) }) - const json = await res.json().catch(() => null) as any - if (res.ok && isSignService(json)) { - probedServer = PROBE_BASE - console.log(`[qs/mssdk] 探测到本机 16689 是汽水签名服务(mssdk-sign v${json.version ?? '?'},dll ${json.dllVersion ?? '?'}),自动接入`) - } else { - probedServer = null - console.log(`[qs/mssdk] 本机 16689 有服务但不是汽水签名服务(healthz service=${json?.service ?? '非 JSON'}),忽略`) - } - } catch { - probedServer = null // 端口没人监听是常态,静默 - } -} - -/** 确保探测完成(进程内只探一次);显式配置了 QS_SIGN_SERVER 则永不探测 */ -const ensureProbe = async (): Promise => { - if (probedServer !== undefined || process.env.QS_SIGN_SERVER?.trim()) return - probing ??= probeOnce() - await probing -} - -// 启动即探测一次(异步进行,不阻塞导入);探测期间进来的签名调用会等它出结果 -void ensureProbe() - -/** 固定值配置(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 - 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() ?? '' } - if (probedServer) { - // 探测接入;签名服务开了鉴权时同样吃 QS_SIGN_TOKEN - return { kind: 'server', server: probedServer, token: process.env.QS_SIGN_TOKEN?.trim() ?? '' } - } - const fixed = readFixedConfig() - return fixed ? { kind: 'fixed', fixed } : null -} - -/** PC 签名是否可用(只看配置与探测结果;服务器宕机等运行期失败由签名调用返回 null、调用方降级处理) */ -export const mssdkAvailable = (): boolean => - // 探测未完成时先放行——紧随其后的 signQishuiRequest 会等探测结果再定供给 - Boolean(loadSignMode()) || probedServer === undefined - -const signViaServer = async ( - server: string, - token: string, - url: string, - headers: Record, - body?: string, -): Promise | 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 = { ...(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, - body?: string, -): Promise | null> => { - await ensureProbe() // 探测未完成时先等它出结果,再决定供给方式 - 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 => { - 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 } diff --git a/sdk/qs/musicInfo.ts b/sdk/qs/musicInfo.ts deleted file mode 100644 index 618c2ad..0000000 --- a/sdk/qs/musicInfo.ts +++ /dev/null @@ -1,31 +0,0 @@ -// 汽水音乐曲目信息(H5 SEO 通道) -// 接口: https://beta-luna.douyin.com/luna/h5/seo_track?track_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 => { - 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` -} diff --git a/sdk/qs/musicSearch.ts b/sdk/qs/musicSearch.ts deleted file mode 100644 index ec5a73b..0000000 --- a/sdk/qs/musicSearch.ts +++ /dev/null @@ -1,91 +0,0 @@ -// 汽水音乐搜索(App 搜索接口直连) -// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor= -// 官方 PC 通道: luna_pc 参数 + X-Helios/X-Medusa 签名(签名服务器或固定值,见 sdk/qs/mssdk.ts)。 -// PC 签名不可用时搜索直接报错;被风控时上游返回 200 + 空 body(无 result_groups)。 -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' - -const PC_PAGE_SIZE = 20 - -const buildSearchUrl = (str: string, cursor: number): string => { - const params = new URLSearchParams({ - ...pcCommonParams(), - q: str, - cursor: String(cursor), - search_id: '', - search_method: 'input', - debug_params: '', - from_search_id: '', - search_scene: 'normal', - scene_name: 'search', - }) - 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' as const, - } - }, - async search(str, page = 1) { - if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)') - // PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠 - const cursor = PC_PAGE_SIZE * (page - 1) - const url = buildSearchUrl(str, cursor) - // 签名覆盖真实发出的头:先定头、再签、再发 - const headers: Record = { 'User-Agent': PC_UA } - const signed = await signQishuiRequest(url, headers) - if (!signed) throw new Error('官方签名失败') - const { body } = await httpFetch(url, { method: 'get', headers: { ...headers, ...signed }, timeout: 15000 }).promise - // 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}) - if (!body?.result_groups) throw new Error('搜索无结果(疑似风控)') - return this.handleSearchBody(body, cursor, page) - }, -} diff --git a/sdk/qs/passport.ts b/sdk/qs/passport.ts deleted file mode 100644 index 5147f74..0000000 --- a/sdk/qs/passport.ts +++ /dev/null @@ -1,369 +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=&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' - -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', -} - -/** - * PC 设备身份(同步读,首次懒生成并持久化):passport 登录与 PC 签名公共参数共用, - * 持久化于 data/auth.json 的 `_qs_passport_device`,保证同一实例「同一设备」。 - */ -export const getDeviceIds = (): { deviceId: string; installId: string } => { - 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 -} - -// 各 passport 请求共用的公共参数(设备身份 + 平台/版本) -const commonParams = (): Record => { - 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[] => { - const raw = headers?.['set-cookie'] - return Array.isArray(raw) ? raw : raw ? [raw] : [] -} - -const cookiePairsFrom = (headers: Record): string[] => - cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean) - -const cookieMapFrom = (headers: Record): Record => { - const out: Record = {} - 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 => { - 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() - -/** 官方扫码页地址:响应 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): 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> => { - 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 || '' - -// ---------- 手机验证码登录(接口与官方 PC 客户端 login.asar 的 WebInterfaceSdk 同款)---------- -// POST /passport/web/send_code/ { mobile: "86 13800138000", type: 24 } -// POST /passport/web/sms_login/ { mobile, code } → Set-Cookie sessionid - -/** 官方 SDK 的 mobile 参数是 "区号 号码" 空格拼接(如 "86 13800138000") */ -const normalizeMobile = (mobile: string, cc = '86'): string => { - const s = String(mobile || '').trim() - const withCc = s.match(/^(\d{1,3})[\s-]+(\d{4,15})$/) - if (withCc) return `${withCc[1]} ${withCc[2]}` - return `${cc} ${s.replace(/\D/g, '')}` -} - -const smsUpstream = async (path: string, form: Record, cookie = ''): Promise<{ data: Record; message: string; headers: Record }> => { - const query = new URLSearchParams(commonParams()) - const request = httpFetch(`${PASSPORT_HOST}${path}?${query}`, { - method: 'post', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...webHeaders(cookie) }, - body: new URLSearchParams(form).toString(), - timeout: 15000, - }) - const { statusCode, headers, body } = await request.promise - if (statusCode != 200) throw Object.assign(new Error(`汽水接口 HTTP ${statusCode}`), { status: 502 }) - return { data: (body as any)?.data ?? {}, message: String((body as any)?.message ?? ''), headers } -} - -// send_code 下发的 cookie(验证码会话),sms_login 时带上 -const smsCookies = new Map() - -export const sendSmsCode = async (mobile: string, cc?: string): Promise<{ sent: true; mobile: string }> => { - const norm = normalizeMobile(mobile, cc) - const { data, message, headers } = await smsUpstream('/passport/web/send_code/', { - mobile: norm, - type: '24', // 官方登录页固定 24(登录注册);16 为仅注册 - }) - if (message !== 'success') { - throw Object.assign(new Error(data.description || `验证码发送失败 (code=${data.error_code})`), { status: 400 }) - } - smsCookies.set(norm, mergeCookies('', cookieMapFrom(headers))) - return { sent: true, mobile: norm } -} - -// 官方登录页的失败文案映射(login-7a4b1afd.js catch 分支) -const smsFailMessage = (data: Record): string => { - if (data.error_code === 1075) return '账号注销中,请前往抖音放弃注销' - if (data.error_code === 2046) return '需要二次验证,请在汽水 App 完成安全验证后重试' - return data.description || `登录失败 (code=${data.error_code})` -} - -export const smsLogin = async (mobile: string, code: string, cc?: string): Promise> => { - const norm = normalizeMobile(mobile, cc) - const { data, message, headers } = await smsUpstream('/passport/web/sms_login/', { - mobile: norm, - code: String(code ?? '').trim(), - }, smsCookies.get(norm) || '') - - let cookie = mergeCookies(smsCookies.get(norm) || '', cookieMapFrom(headers)) - smsCookies.delete(norm) - - // 成功但 sessionid 未随本响应下发时补跳 redirect_url(与扫码确认同款) - if (message === 'success' && !sessionIdOf(cookie) && data.redirect_url) { - try { - const hop = await httpFetch(String(data.redirect_url), { method: 'get', headers: webHeaders(cookie), timeout: 15000 }).promise - cookie = mergeCookies(cookie, cookieMapFrom(hop.headers)) - } catch { - // 补跳失败不致命:sessionid 缺失时按失败处理 - } - } - - const sessionid = sessionIdOf(cookie) - if (message === 'success' && sessionid) { - setCredentials('qs', { sessionid, cookie }) - return { code: 803, message: '登录成功', errorCode: 0 } - } - throw Object.assign(new Error(message === 'success' ? '登录响应缺少 sessionid' : smsFailMessage(data)), { status: 400 }) -} - -/** - * 手动绑定 sessionid(汽水 App / 官方 PC 客户端抓包,或已登录浏览器 Cookie)。 - * 接受裸 sessionid 值或完整 cookie 串(须含 sessionid= 字段);完整串优先整体保存, - * sid_guard 等伴随字段有利于延长登录态寿命。passport 无可用的免签校验接口 - * (/passport/web/user_info/ 对本 aid 恒报 20016 无权限),不做上游验证,失效会在取流时暴露。 - */ -export const loginBySessionId = async(input: string): Promise<{ logged: true; sessionid: string }> => { - const raw = String(input || '').trim() - const sessionid = sessionIdOf(raw) || (/^[\w-]{10,}$/.test(raw) ? raw : '') - if (!sessionid) throw Object.assign(new Error('需提供 sessionid 值或含 sessionid= 字段的 cookie'), { status: 422 }) - const cookie = raw.includes('=') ? raw : `sessionid=${sessionid}` - setCredentials('qs', { sessionid, cookie }) - return { logged: true, sessionid } -} - -/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */ -export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || '' - -export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus, sendSmsCode, smsLogin, loginBySessionId } diff --git a/sdk/types.ts b/sdk/types.ts index f614622..3cc6512 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -3,7 +3,7 @@ /** 音乐平台标识 */ export type Source = - | 'kw' | 'kg' | 'tx' | 'wy' | 'qs' + | 'kw' | 'kg' | 'tx' | 'wy' | 'mg' | 'bd' | 'joox' | 'bili' | '5sing' | 'apple' | 'xm' | 'me' | 'qt' | 'lr' diff --git a/src/http/stream-sign.ts b/src/http/stream-sign.ts index 12d772d..ce768ae 100644 --- a/src/http/stream-sign.ts +++ b/src/http/stream-sign.ts @@ -3,13 +3,13 @@ // 与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口 import crypto from 'node:crypto' -// 流地址有效期(毫秒),与 qs 流缓存 TTL(10 分钟)对齐;过期后客户端重新 /url 即可 +// 流地址有效期(毫秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可 export const STREAM_SIGN_TTL = 10 * 60_000 const hmac = (authKey: string, data: string) => 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) => { const exp = Math.floor((Date.now() + STREAM_SIGN_TTL) / 1000) return `exp=${exp}&sig=${hmac(authKey, `${path}:${exp}`)}` diff --git a/src/routes-login.ts b/src/routes-login.ts index ea1955b..09f5215 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -1,11 +1,11 @@ -// 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie,qs 扫码/短信/sessionid, +// 登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/token,tx 扫码/cookie, // xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json +// (汽水音乐登录已随 qs 平台拆分至独立服务端 SodaServer) // 注意:成功响应返回裸数据对象,全局 preSerialization 钩子会统一包成 { code: 0, data } import * as wyAuth from '../sdk/auth/wy.ts' import * as kgAuth from '../sdk/auth/kg.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 { status as xmStatus } from '../sdk/xm/index.ts' import { status as meStatus } from '../sdk/me/index.ts' @@ -309,109 +309,6 @@ export const loginRoutes = async(fastify) => { 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.post('/login/qs/sms', { - schema: { - summary: '发送汽水音乐登录短信验证码', - description: '手机号验证码登录第一步(官方 PC 客户端同款 /passport/web/send_code/,type=24)。' + - 'mobile 支持 "13800138000" 或自带区号 "86 13800138000";海外号码用 cc 参数指定区号。' + - '发送成功后用 /login/qs/sms/login 提交验证码完成登录。', - tags: ['登录'], - body: { - type: 'object', - properties: { mobile: { type: 'string' }, cc: { type: 'string' } }, - required: ['mobile'], - }, - }, - }, async(request, reply) => { - try { - return await qsPassport.sendSmsCode(request.body.mobile, request.body.cc) - } catch (err) { - return fail(reply, err) - } - }) - - fastify.post('/login/qs/sms/login', { - schema: { - summary: '汽水音乐短信验证码登录', - description: '提交 /login/qs/sms 发送的验证码完成登录,成功自动保存凭据并返回 803。' + - '失败经 msg 返回官方文案(1200-1300 为验证码/手机号错误;2046 需去汽水 App 完成安全验证;1075 账号注销中)。', - tags: ['登录'], - body: { - type: 'object', - properties: { mobile: { type: 'string' }, code: { type: 'string' }, cc: { type: 'string' } }, - required: ['mobile', 'code'], - }, - }, - }, async(request, reply) => { - try { - return await qsPassport.smsLogin(request.body.mobile, request.body.code, request.body.cc) - } catch (err) { - return fail(reply, err) - } - }) - - fastify.post('/login/qs', { - schema: { - summary: '绑定汽水音乐 sessionid', - description: 'sessionid 从汽水 App / 官方 PC 客户端抓包获取(登录态 Cookie 的 sessionid 字段),' + - '也可传完整 cookie 串(须含 sessionid= 字段,整体保存)。无上游校验接口,不做有效性验证,' + - '失效会在取流时报错。推荐优先用 /login/qs/qr 扫码或 /login/qs/sms 短信登录。', - tags: ['登录'], - body: { - type: 'object', - properties: { sessionid: { type: 'string' }, cookie: { type: 'string' } }, - }, - }, - }, async(request, reply) => { - try { - return await qsPassport.loginBySessionId(request.body.sessionid || request.body.cookie) - } catch (err) { - return fail(reply, err) - } - }) - - fastify.delete('/login/qs', { - schema: { summary: '退出汽水音乐登录', tags: ['登录'] }, - }, async() => { - clearCredentials('qs') - return { logged: false } - }) - // ---------- 喜马拉雅 ---------- fastify.post('/login/xm', { @@ -559,7 +456,7 @@ export const loginRoutes = async(fastify) => { schema: { summary: '查看登录状态', 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: ['登录'], }, }, async() => { @@ -571,7 +468,6 @@ export const loginRoutes = async(fastify) => { wy: wy ? { logged: true, ...wy } : { 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 }, - qs: qsPassport.status(), xm: xmStatus(), me: meStatus(), lr: lrAuth.status(), diff --git a/src/routes.ts b/src/routes.ts index 90757cf..a574575 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -183,7 +183,7 @@ export const musicRoutes = async(fastify: any) => { if (res.encrypted && res.fileKey) { rawUrl = `/stream/mg/${registerMiguStream(rawUrl, String(res.fileKey))}` } - // qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器); + // mg 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器); // 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身) let url = rawUrl if (url.startsWith('/')) { @@ -240,7 +240,7 @@ export const musicRoutes = async(fastify: any) => { fastify.get('/url/:source/:id', { schema: { summary: '获取播放地址', - description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs/xm/me 用声音 id,qt 为 :(见 /album/qt),lr 为 :
(见 /album/lr)。' + + description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,xm/me 用声音 id,qt 为 :(见 /album/qt),lr 为 :
(见 /album/lr)。' + '取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' + '酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' + '其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。', @@ -260,41 +260,25 @@ export const musicRoutes = async(fastify: any) => { }, }, urlHandler) - // 加密流解密代理(qs 汽水解密流 / mg 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。 - // - qs:/url/qs 登录态取流返回相对地址 /stream/qs/,本端点完成"签名 track_v2 → - // 密音频下载 → spade key + AES-CTR 解密 → 回明文流" - // - mg:/url/mg 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/mg/<票据>,本端点凭票下载密流逐字节解密 - // 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装 + // 加密流解密代理(mg 咪咕 Z3D/I3D 加密流):/url/mg 命中加密候选时登记票据并返回 + // /stream/mg/<票据>,本端点凭票下载密流逐字节解密;由 /url 产出相对地址,路由层负责补全 origin; + // raw 豁免 { code, data } 信封包装(汽水解密流已拆分至独立服务端 SodaServer) fastify.get('/stream/:source/:id', { config: { raw: true }, schema: { - summary: '加密流解密代理(qs / mg)', - description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/mg 命中加密流时的地址),' + + summary: '加密流解密代理(mg)', + description: '取流响应中返回的相对地址(/url/mg 命中加密流时的地址),' + '服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' + - 'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' + - 'source=mg 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。', + 'id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/mg 取流)。', tags: ['歌曲资源'], params: params({ - source: { type: 'string', enum: ['qs', 'mg'], description: '平台 id:qs=汽水音乐解密流 / mg=咪咕加密流', example: 'qs' }, - id: { type: 'string', description: 'qs: 汽水 track id;mg: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' }, + source: { type: 'string', enum: ['mg'], description: '平台 id:mg=咪咕加密流', example: 'mg' }, + id: { type: 'string', description: '取流响应 url 中的票据段(无需自行构造)', example: '7123456789' }, }), }, }, async(request, reply) => { 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)) if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/mg 取流)') diff --git a/src/server.ts b/src/server.ts index 4e8e2cf..f92677d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,7 +20,7 @@ // GET /playlist/:source/categories[/:categoryId] 歌单分类 // POST /parse/{song|album|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 // // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 @@ -48,8 +48,8 @@ const PORT = process.env.PORT || 16688 const DOCS_PREFIX = '/docs' const API_TITLE = 'MusicServer 聚合音乐 API' const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' + - '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ mg(咪咕)/ bd(千千)/ ' + - 'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。' + '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)/ bd(千千)/ ' + + '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')) @@ -178,7 +178,7 @@ fastify.register(swagger, { { name: '榜单', description: '排行榜' }, { name: '歌单', description: '歌单与热搜' }, { name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' }, - { name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie、qs 抖音扫码)与状态' }, + { name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie)与状态' }, { name: '系统', description: '健康检查、平台信息、文档' }, ], }, diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 2546b6b..20064de 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -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() => { const r = await get(`/search/${source}/${encodeURIComponent('稻香')}?limit=2`) 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) }) -// 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 实测可用) await test('URL xm 免费曲', async() => { @@ -338,12 +331,6 @@ await test('歌词 kw', async() => { 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() => { @@ -353,12 +340,6 @@ await test('封面 kw', async() => { 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']) { @@ -678,12 +659,12 @@ await test('链接解析 /parse/album/apple', async() => { }) await test('链接解析 不支持的平台 → 404', async() => { - // qs 无 parse 实现(以 /sources 的 parse 能力位为准)。 + // xm 无 parse 实现(以 /sources 的 parse 能力位为准)。 // 原先此处断言 kw 返回 404 —— 但 kw 的 parse 早已实现(sdk/kw/parse.ts), // 该断言过期后恒失败,导致 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}`) - return 'qs 未实现 parse(按上游)' + return 'xm 未实现 parse(按上游)' }) await test('链接解析 /parse/song/kw(正向)', async() => { @@ -846,49 +827,6 @@ await test('wy 假 cookie 拒绝 POST /login/wy', async() => { return r.body?.msg }) -await test('扫码二维码创建 POST /login/qs/qr', async() => { - const r = await postJson('/login/qs/qr', {}) - if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`) - if (!r.body.data?.token || !r.body.data?.qrimg?.startsWith('data:image/')) throw new Error('响应不完整') - // 二维码必须是官方 PC 客户端同款扫码页(旧的 ucenter_web/app/sdk-next 已 404,抖音扫出「无法访问页面」) - if (!String(r.body.data?.scanUrl ?? '').includes('/light/invoke/scan_login')) throw new Error(`二维码内容非官方扫码页: ${r.body.data?.scanUrl}`) - return `scanApp=${r.body.data.scanApp || '?'}` -}) - -// 短信发码:无效手机号应被上游业务拒绝(400 + 官方文案),而非 502 网关错 -await test('sms 坏手机号拒绝 POST /login/qs/sms', async() => { - const r = await postJson('/login/qs/sms', { mobile: '10000000000' }) - if (r.status !== 400 || !r.body?.msg) throw new Error(`期望 400+msg,实际 ${r.status} ${JSON.stringify(r.body)}`) - return r.body.msg -}) - -// 短信登录:未发码直接提交,应被上游业务拒绝(400 + 官方文案) -await test('sms 错误验证码拒绝 POST /login/qs/sms/login', async() => { - const r = await postJson('/login/qs/sms/login', { mobile: '10000000000', code: '000000' }) - if (r.status !== 400 || !r.body?.msg) throw new Error(`期望 400+msg,实际 ${r.status} ${JSON.stringify(r.body)}`) - return r.body.msg -}) - -// qs 手动绑定:裸 sessionid 与完整 cookie 串两种形态均可绑,登录态生效; -// 无上游校验接口,测完解绑恢复匿名(避免假 sessionid 影响后续 qs 用例) -await test('qs sessionid 绑定 POST /login/qs', async() => { - const bare = await postJson('/login/qs', { sessionid: 'smoke_fake_sessionid_0123456789abcdef' }) - if (bare.status !== 200 || bare.body?.code !== 0 || bare.body.data?.logged !== true) throw new Error(`HTTP ${bare.status} ${JSON.stringify(bare.body)}`) - const full = await postJson('/login/qs', { cookie: 'sid_guard=smoke; sessionid=smoke_fake_sessionid_0123456789abcdef' }) - if (full.status !== 200 || full.body?.code !== 0) throw new Error(`HTTP ${full.status} ${JSON.stringify(full.body)}`) - const st = await get('/login/status') - if (st.code != 0 || st.data?.qs?.logged !== true) throw new Error('qs 登录状态未生效') - const del = await fetch(`${BASE}/login/qs`, { method: 'DELETE' }) - if (del.status !== 200) throw new Error(`解绑失败 HTTP ${del.status}`) - return 'bare+cookie 两种形态可绑,已解绑' -}) - -// qs cookie 绑定校验(缺 sessionid 字段拒绝) -await test('qs 坏 cookie 拒绝 POST /login/qs', async() => { - const r = await postJson('/login/qs', { cookie: 'foo=bar; baz=1' }) - if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) - return `status ${r.status}` -}) await test('kg 假 token 拒绝 POST /login/kg', async() => { const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 }) @@ -960,23 +898,23 @@ if (!externalServer && !process.env.AUTH_KEY) { // /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃 await test('AUTH_KEY 流代理签名机制', async() => { // 裸秘钥 ?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}`) // 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 应通过流代理鉴权') // 正确签名通过;错签名 401 const exp = Math.floor(Date.now() / 1000) + 600 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 signed = await authFetch(`/stream/qs/123?exp=${exp}&sig=${sig}`) + const sig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/mg/123:${exp}`).digest('hex').slice(0, 32) + const signed = await authFetch(`/stream/mg/123?exp=${exp}&sig=${sig}`) 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}`) // 过期签名 401 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 expired = await authFetch(`/stream/qs/123?exp=${oldExp}&sig=${oldSig}`) + const oldSig = crypto.createHmac('sha256', AUTH_TEST_KEY).update(`/stream/mg/123:${oldExp}`).digest('hex').slice(0, 32) + const expired = await authFetch(`/stream/mg/123?exp=${oldExp}&sig=${oldSig}`) if (expired.status !== 401) throw new Error(`过期签名应 401,实际 ${expired.status}`) return '头/签名通过,裸key/错签/过期签均 401' })