Compare commits
7
Commits
cca7e63ec3
...
14f7295fd4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14f7295fd4 | ||
|
|
dd28ba157c | ||
|
|
de2dd311ee | ||
|
|
68e071b166 | ||
|
|
33b96f92c4 | ||
|
|
47ce651f46 | ||
|
|
ece9c5fc9a |
No files matched your search
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(ls -la \"C:\\\\Users\\\\Administrator\\\\.claude\\\\projects\\\\d--MusicServer\\\\memory\" 2>/dev/null; cat \"C:\\\\Users\\\\Administrator\\\\.claude\\\\projects\\\\d--MusicServer\\\\memory\\\\MEMORY.md\" 2>/dev/null; ls /d/MusicServer/*.md 2>/dev/null)",
|
||||
"Read(//c/Users/Administrator/.claude/projects/d--MusicServer/memory/**)",
|
||||
"Bash(grep -rn \"from '\\\\.\\\\./\\\\.\\\\./request'\" /d/MusicServer/musicSdk --include=\"*.js\" -l)",
|
||||
"Bash(grep -v \"from '\\\\./\\\\|from '\\\\.\\\\./\\\\|from '\\\\.\\\\./\\\\.\\\\./\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,10 @@ Thumbs.db
|
||||
# ZCode 本地目录
|
||||
.zcode/
|
||||
|
||||
# Agent 工作目录(本机记忆日志、工具权限配置;含本机绝对路径,不入库)
|
||||
.workbuddy/
|
||||
.claude/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
@@ -38,3 +42,6 @@ coverage/
|
||||
# 以下规则仅作保险,防止旧的发射产物被误生成后提交。
|
||||
src/**/*.js
|
||||
sdk/**/*.js
|
||||
|
||||
# 汽水官方签名原生模块(专有二进制,不入库;QS_MSSDK_DIR 亦可指向别处)
|
||||
soda-mssdk/
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# MusicServer 全量代码检查报告
|
||||
|
||||
**检查时间**:2026-10-03
|
||||
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
|
||||
第五节「文档与代码不一致」已按 `/sources` 与路由实现逐条校准;第八节列出的未复核线索与第四节 P2 项**未动**,留待后续。
|
||||
各条的修复要点见下文对应条目。
|
||||
**检查范围**:`src/`(15 文件 / 3,103 行)、`sdk/`(173 文件 / 21,919 行)、`test/`(2 文件 / 1,081 行),共约 26,100 行 TypeScript
|
||||
|
||||
**检查方法**:
|
||||
1. 静态门禁:`tsc`、`tsc --noUnusedLocals --noUnusedParameters`、离线音源测试、冒烟测试
|
||||
2. 全量模式扫描:`any` 滥用、`@ts-ignore`、TODO/FIXME、调试输出、死代码、硬编码敏感值
|
||||
3. 四组分区深度审查:HTTP 层 / SDK 核心(认证+取流)/ SDK 平台模块 / 测试与文档
|
||||
4. 针对性复现验证:对判定为 P0 的问题编写最小复现脚本确认
|
||||
|
||||
---
|
||||
|
||||
## 一、基线状态
|
||||
|
||||
| 检查项 | 结果 |
|
||||
| --- | --- |
|
||||
| `pnpm typecheck`(tsc strict) | ✅ 通过 |
|
||||
| 未使用符号(`--noUnusedLocals`) | ⚠️ 26 处 |
|
||||
| 离线测试 `test/source-simple-test.ts` | ✅ 8/8 通过 |
|
||||
| 冒烟测试 `test/smoke-test.ts` | ⚠️ **不稳定**:一次 110/111,一次直接崩溃(见 P1-4) |
|
||||
| `@ts-ignore` / `@ts-expect-error` | ✅ 0 处(纪律良好) |
|
||||
| TODO / FIXME / HACK | ✅ 0 处 |
|
||||
| lint / CI 配置 | ❌ 均无(质量门禁仅 tsc + 冒烟测试) |
|
||||
|
||||
---
|
||||
|
||||
## 二、P0 阻断级
|
||||
|
||||
### P0-1 `POST /login/lr` 路由缺失 —— 懒人听书取流 100% 不可用
|
||||
|
||||
**证据链**:
|
||||
- `README.md:86` 与 `README.md:105` 明确承诺:懒人听书需 `POST /login/lr` 账密登录,且「取流必须登录(免费章节同样要求登录态)」
|
||||
- `sdk/lr/auth.ts:12` **已完整实现** `login()`(base64 密码 + 会话 Cookie 持久化)
|
||||
- 但 `sdk/lr/index.ts:53` 只写了 `export { status } from './auth.ts'` —— **没有导出 `login`**
|
||||
- `src/routes-login.ts` 注册的 28 条路由中**没有任何 lr 相关条目**(逐条列举验证)
|
||||
|
||||
**影响**:sdk 层实现了登录能力,却没有任何 HTTP 入口。而 lr 取流强制要求登录态 → **该平台的取流功能完全无法使用**,README 的承诺是空头支票。
|
||||
|
||||
**修复**:`sdk/lr/index.ts` 补导出 `login`;`src/routes-login.ts` 参照 `/login/xm` 补 `POST /login/lr`(body: `{account, password}`)与 `DELETE /login/lr`;`/login/status` 加入 `lr` 字段。
|
||||
|
||||
---
|
||||
|
||||
### P0-2 咪咕解密流异常逃逸 —— 可导致整个服务进程崩溃
|
||||
|
||||
**证据链**:
|
||||
- `src/http/migu-stream.ts:88-92`:`res.on('data', chunk => { ...; onChunk(plain, offset) })` —— `onChunk` 在 EventEmitter 回调中**同步调用**
|
||||
- `src/routes.ts:317`:该回调内部有 `throw new Error('解密结果不是有效音频')`
|
||||
- 外层 `src/routes.ts:311-324` 的 `try/catch` 只能捕获 Promise 链上的 reject,**捕不到** EventEmitter 回调抛出的异常
|
||||
- 全项目**没有任何** `uncaughtException` / `unhandledRejection` 处理器(grep 无匹配)
|
||||
|
||||
**复现验证**(`tmp/repro-migu-uncaught.mjs`,已实测):
|
||||
```
|
||||
❌ uncaughtException 逃逸(进程会崩溃): 解密结果不是有效音频
|
||||
exit code 9
|
||||
```
|
||||
|
||||
**影响**:当咪咕返回的密流与票据 `fileKey` 不匹配(解密结果非法),或客户端中途断开导致 `reply.raw.write` 抛错时,异常逃逸 → **整个 Node 进程崩溃**,所有在线用户连接同时中断。这是一个远程可触发的单点崩溃。
|
||||
|
||||
**修复**:`onChunk` 内不要 `throw`,改为置错误标志 + `res.destroy()` / `reply.raw.destroy()`;或让 `pipeDecryptedMiguStream` 把 `transform`/`onChunk` 的调用包进 `try/catch` 并 `reject`。另建议补一个全局 `uncaughtException` 兜底(至少记日志后优雅退出,而非静默崩溃)。
|
||||
|
||||
---
|
||||
|
||||
### P0-3 登录凭据明文落盘且权限过宽
|
||||
|
||||
**证据链**:
|
||||
- `sdk/auth/store.ts:88`:`writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))` —— 明文 JSON,且**未传 `{ mode: 0o600 }`**
|
||||
- 实际文件:`data/auth.json` 权限 `-rw-r--r--`(0644),内容为各平台 cookie/token 明文
|
||||
- 文件覆盖 wy / kg / tx / xm / me / lr / bili / qs 等全部平台凭据
|
||||
|
||||
**影响**:任何能读取该目录的本地用户或进程可直接窃取全部平台登录态(等价于账号取流权限)。Windows 上 POSIX 位不完全生效,风险取决于 ACL,但**明文存储**这一点在任意平台都成立。
|
||||
|
||||
**修复**:`writeFileSync(..., { mode: 0o600 })`(Windows 侧另设 ACL);如需更高保障再考虑加密存储(密钥来自环境变量)。
|
||||
|
||||
---
|
||||
|
||||
## 三、P1 应修
|
||||
|
||||
### P1-4 冒烟测试异常路径不清理子实例 → 僵尸进程累积
|
||||
|
||||
**证据**:
|
||||
- `test/smoke-test.ts:81-110` `startLocalServer()` 超时后 `proc.kill()` 再 `reject`
|
||||
- 主实例之外还有两个**额外实例**:`authServer`(:835)、`rlServer`(:887)
|
||||
- `:898-899` 的 `rlServer.kill()` / `authServer.kill()` 位于顶层顺序执行,**不在 try/finally 内** —— 一旦中间抛错就不会执行
|
||||
- `:911-915` 顶层只清理了 `mainServer`(`localServer`),**不涉及额外实例**
|
||||
|
||||
**实测后果**:当前机器上有 **6 个 MusicServer 实例在监听**:`16688`(常驻)+ `16690/16691/16692/16693/16790`(测试泄漏,连续端口说明是多轮泄漏累积)。本次审查中冒烟测试崩溃后即留下新实例。
|
||||
|
||||
**影响**:每次测试异常就泄漏一个监听端口的 node 进程,长期累积占用内存/端口,且会干扰后续测试判定。
|
||||
|
||||
**修复**:用 `try/finally` 或 `process.on('exit')` 统一 kill 所有已启动实例。
|
||||
|
||||
---
|
||||
|
||||
### P1-5 冒烟测试启动超时 10s 硬编码,且失败时无任何诊断信息
|
||||
|
||||
**证据**:`test/smoke-test.ts:91-94`
|
||||
```js
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error('启动本地服务器超时')) // ← 未附 stdout
|
||||
}, 10000)
|
||||
```
|
||||
`stdout` 变量已收集子进程输出(:87-89),但 reject 时完全丢弃。
|
||||
|
||||
**影响**:本次审查中同一份代码**一次通过(110/111)、一次崩溃**,说明该超时在并发/负载下很脆弱 —— 属于典型的 flaky 测试。而失败信息只有一句「启动本地服务器超时」,无法判断是端口被占、TS 报错还是单纯慢。
|
||||
|
||||
**修复**:超时提到 30s;错误信息附带 `stdout` 尾部;区分「进程已退出」与「进程存活但未就绪」。
|
||||
|
||||
---
|
||||
|
||||
### P1-6 `/song-list/:source` 的 `?limit=` 被文档承诺却静默忽略
|
||||
|
||||
**证据**:
|
||||
- `src/routes.ts:450`:querystring schema 声明了 `limit: pLimit`
|
||||
- `src/routes.ts:436`:handler 签名为 `(source, page, sortId, tagId)` —— **没有 limit 参数**
|
||||
- `src/routes.ts:458`:调用时也未传 `request.query.limit`
|
||||
|
||||
**影响**:OpenAPI 文档告诉客户端可以传 `?limit=`,实际被丢弃 —— API 契约与实现不符。
|
||||
|
||||
**修复**:要么把 limit 透传到 `songList.getList()`,要么从 schema 移除。
|
||||
|
||||
---
|
||||
|
||||
### P1-7 凭据文件写入非原子 + 解析失败静默清空
|
||||
|
||||
**证据**(`sdk/auth/store.ts`):
|
||||
- `:84-89` `save()` 直接 `writeFileSync` 覆盖原文件,无 temp+rename。写入过程中进程被杀 → 文件损坏
|
||||
- `:73-82` `load()` 用 `catch { cache = {} }` 静默吞掉 JSON 解析错误;随后任何 `setCredentials()` 都会用空数据**覆盖重写**损坏文件 → **所有平台凭据一次性丢失**,且无任何日志
|
||||
- 无跨进程锁:当前环境并存 6 个实例,写操作会互相覆盖
|
||||
|
||||
**修复**:写临时文件 + `rename()` 原子替换;解析失败时先备份原文件再重置,并打印告警。
|
||||
|
||||
---
|
||||
|
||||
### P1-8 过期/错误注释误导维护者
|
||||
|
||||
**证据**:`sdk/auth/store.ts:3`
|
||||
```
|
||||
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
|
||||
```
|
||||
但:`tsconfig.json` 已是 `noEmit: true`,`package.json` **没有 `build` 脚本**,项目已全面改为「无编译产物、改完即跑」。磁盘上也确认无 `.js` 残留。
|
||||
|
||||
**影响**:按注释执行 `pnpm build` 会失败,且误导开发者以为存在发射产物。
|
||||
|
||||
**修复**:删掉该行注释。
|
||||
|
||||
---
|
||||
|
||||
### P1-9 酷狗 krc 歌词解码:Promise 永不 settle,请求挂死
|
||||
|
||||
**证据**:`sdk/kg/krcDecode.ts:9-10`
|
||||
```js
|
||||
const decodeLyric = str => new Promise((resolve, reject) => {
|
||||
if (!str.length) return // ← 既未 resolve 也未 reject
|
||||
```
|
||||
`return` 只结束了执行器函数,Promise 永久停在 pending。
|
||||
|
||||
**影响**:上游返回空 krc 歌词时,调用方的 `await` 永久挂起 —— 连接与内存无法释放,客户端只能等自己超时。
|
||||
|
||||
**修复**:`if (!str.length) return resolve('')`
|
||||
|
||||
---
|
||||
|
||||
### P1-10 酷狗歌词重试调错方法
|
||||
|
||||
**证据**:`sdk/kg/lyric.ts:72` —— 在 `getLyricDownload(id, accessKey, fmt, tryNum)` 内部重试时调用的是
|
||||
```js
|
||||
let tryRequestObj = (this.getLyric as any)(id, accessKey, fmt, ++tryNum)
|
||||
```
|
||||
应为 `this.getLyricDownload(...)`。`getLyric` 与 `getLyricDownload` 参数语义不同,且 `as any` 恰好掩盖了这个错误。
|
||||
|
||||
**影响**:HTTP 非 200 时的重试实际走了另一个方法,重试逻辑失效(还会掩盖类型错误)。
|
||||
|
||||
**修复**:改为 `this.getLyricDownload(id, accessKey, fmt, ++tryNum)`;顺带移除 `as any`。
|
||||
|
||||
---
|
||||
|
||||
### P1-11 酷我歌单详情:重试参数错位(序号被当成页码)
|
||||
|
||||
**证据**:
|
||||
- `sdk/kw/songList.ts:196`:`return this.getListDetail(id, ++tryNum)`
|
||||
- `sdk/kw/songList.ts:373`:`getListDetail(id, page, retryNum = 0)` —— 第二个参数是 **page**
|
||||
|
||||
同一文件 `:175`、`:206` 都正确写成 `getListDetail(id, page, ++tryNum)`,唯独 `:196` 漏了 `page`。
|
||||
|
||||
**影响**:重试时请求的页码被替换成 `tryNum`(1/2/3…),而不是用户请求的页;同时重试计数没有真正传递。
|
||||
|
||||
**修复**:补上 `page`,与 `:206` 对齐。
|
||||
|
||||
---
|
||||
|
||||
### P1-12 酷我歌单详情 URL 模板多余 `}`
|
||||
|
||||
**证据**:`sdk/kw/songList.ts:203`
|
||||
```
|
||||
...&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&...
|
||||
↑ 多了一个右花括号
|
||||
```
|
||||
|
||||
**影响**:请求 URL 携带非法字符,目前依赖上游容错;上游一旦收紧解析该接口即失效。
|
||||
|
||||
**修复**:删掉多余的 `}`。
|
||||
|
||||
---
|
||||
|
||||
### P1-13 冒烟测试未做环境隔离:取流用例实际走外部音源,且会改写真实凭据
|
||||
|
||||
**证据**:
|
||||
- `test/smoke-test.ts:84` 以 `env: { ...process.env, PORT, ...envExtra }` 启动子实例 —— 完整继承当前进程环境
|
||||
- `.env:1` 为 `SOURCE_ENABLED=true` → 主实例以**音源模式**启动,`/url` 用例优先走 `sources/` 脚本链
|
||||
- 子实例工作目录即项目根,`data/auth.json` 是**真实凭据文件**(无测试专用路径)
|
||||
|
||||
**影响**:① `/url` 用例并未验证 SDK 官方直连通道,SDK 侧回归可被外部音源掩盖(**测试假通过**);② `/login/refresh` 等用例会读写真实 `data/auth.json` —— 本地跑一次测试就可能覆盖真实登录态。
|
||||
|
||||
**修复**:测试显式传 `SOURCE_ENABLED: ''`;新增 `DATA_DIR` 环境变量让测试实例使用独立数据文件。
|
||||
|
||||
---
|
||||
|
||||
### P1-14 `pnpm-workspace.yaml` 使用非官方键 → isolated-vm 沙箱实际不可用
|
||||
|
||||
**证据**:
|
||||
- `pnpm-workspace.yaml` 全文仅两行:`allowBuilds:` / `isolated-vm: true`
|
||||
- `pnpm --version` = 12.4.1;放行构建脚本的字段应为 `onlyBuiltDependencies`,`allowBuilds` 不生效
|
||||
- `node_modules/isolated-vm` 目录存在,但 `out/*.node` **不存在** → 原生模块从未构建
|
||||
- 离线测试实测输出:`未安装 isolated-vm,无法启用隔离执行音源…切换下一候选脚本`
|
||||
|
||||
**影响**:`src/source-ivm.ts` 的「硬隔离沙箱」在部署时静默失效 —— 社区 `.js` 音源脚本全部被跳过,而 README 把它描述为可用的第一类音源。安全隔离能力名存实亡,且无显著告警。
|
||||
|
||||
**修复**:改为 `onlyBuiltDependencies: [isolated-vm]` 并确认构建产物就位;ivm 不可用时应在启动日志告警。
|
||||
|
||||
---
|
||||
|
||||
### P1-15 冒烟测试断言游离在 `test()` 之外,抛错会跳过清理
|
||||
|
||||
**证据**:`test/smoke-test.ts:855-858` 的「6 个路径均须 401」断言写成裸 `for` 循环置于顶层,而对应的 `await test(...)` 在 `:859`。任一路径返回非 401 即顶层抛错。
|
||||
|
||||
**影响**:抛错直接终止脚本,**跳过结果汇总与末尾的实例清理** —— 与 P1-4 同源,都会留下孤儿 node 进程。
|
||||
|
||||
**修复**:把断言包进 `test()`,或统一用 `try/finally` 收尾。
|
||||
|
||||
---
|
||||
|
||||
### P1-16 `.claude/settings.local.json` 已入库且含本机绝对路径
|
||||
|
||||
**证据**:`git ls-files .claude/` 返回 `.claude/settings.local.json`;内容含
|
||||
`C:\Users\Administrator\.claude\projects\d--MusicServer\memory` 等本机路径。
|
||||
|
||||
**影响**:把本机目录结构与本地工具配置写进(README 声明为公开的)仓库,对他人无意义。
|
||||
|
||||
**修复**:`git rm --cached .claude/settings.local.json`,并把 `.claude/` 加入 `.gitignore`。
|
||||
|
||||
---
|
||||
|
||||
## 四、P2 可维护性与卫生
|
||||
|
||||
| 类别 | 证据 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 注释掉的死代码 | `sdk/wy/lyric.ts:49-51,240`、`sdk/kw/album.ts:242-246`、`sdk/kg/songList.ts:75-109,778`、`sdk/kw/index.ts:33-37` 等 100+ 处 | 大量整段注释代码(含被注释的 `console.log` 调试行),建议清理 |
|
||||
| 裸 `console.log(err)` 吞错 | `sdk/wy/songList.ts:103`、`sdk/wy/leaderboard.ts:188`、`sdk/kw/lyric.ts:142`(还会打印完整歌词)、`sdk/kg/songList.ts:279`、`sdk/kg/util.ts:35`、`sdk/tx/utils/index.ts:39`、`sdk/utils.ts:23` | 吞掉异常且无上下文,排障困难 |
|
||||
| 未使用符号 26 处 | `sdk/index.ts`(3)、`sdk/tx/leaderboard.ts`(2)、`sdk/lr/index.ts`(2)、`sdk/joox/parse.ts`(2)、`sdk/apis/migu.ts`(2) 等 | `tsc --noUnusedLocals` 可复现 |
|
||||
| `.gitignore` 漏 `.workbuddy/` | `.gitignore` 内容 vs `git status` 显示 `?? .workbuddy/` | 工作记忆目录会被 `git add .` 收进仓库 |
|
||||
| `tmp/` 占 297 MB / 4,992 文件 | 内含 1753 js / 720 ts / 339 py / 287 tsx / 122 vue | 已 gitignore,但混有大量与本项目无关的代码样本,建议清理磁盘 |
|
||||
| 无 lint / 无 CI | `.eslintrc`/`.prettierrc`/`.github` 均不存在 | 建议引入 ESLint + 一条 CI 跑类型检查与离线测试 |
|
||||
|
||||
---
|
||||
|
||||
## 五、文档与代码不一致清单
|
||||
|
||||
| 文档位置 | 文档声称 | 代码实际 |
|
||||
| --- | --- | --- |
|
||||
| `README.md:86`、`:105` | `POST /login/lr` 存在 | **不存在**(P0-1) |
|
||||
| `README.md:20`(qs 行) | 歌单 ✅、专辑 ✅ | `sdk/qs/index.ts` 无 `album`/`playlist` 方法,`/sources` 能力位也无 |
|
||||
| `README.md:20` vs `:66` | 内部自相矛盾 | `:20` 给 qs 专辑打勾,`:66` 的专辑详情平台列表却**不含 qs** |
|
||||
| `README.md:71` | parse 支持 `5sing/apple/bili/joox/qian/migu` | 实际还支持 **kw / kg / tx / wy**(`sdk/{kw,kg,tx,wy}/parse.ts` 存在,`/sources` 已暴露 `parse` 位) |
|
||||
| `README.md:68` | 歌单分类仅 `migu/qian/joox/apple` | 实际 **kw / kg / tx / wy** 也有 `playlistCategories` 能力位 |
|
||||
| `README.md` 登录接口表 | 只列了 POST 登录 | 实际还有 7 个 `DELETE /login/{wy,kg,tx,qs,xm,me,bili}` 退出端点未文档化 |
|
||||
| `test/smoke-test.ts:642` | `/parse/song/kw` 期望 404 | 实际 200(kw parse 已实现)→ 导致冒烟测试唯一的失败项 |
|
||||
| `sdk/auth/store.ts:3` | 改完需 `pnpm build` | 无 build 脚本,项目无编译产物(P1-8) |
|
||||
| `README.md:24` | bili 音质 `64k/128k/192k` | `/sources` 实际为 `64k/128k/320k/flac/flac24bit` |
|
||||
| `README.md:65` | `/album/:source/:id?page=1`(query 分页) | 分页是 path 段 `/album/{source}/{id}/{page}` |
|
||||
| `README.md:29-30` | qt / lr 封面标「—」 | `/sources` 显示两者 `pic=true`(实际支持) |
|
||||
| `README.md:72` | 个人歌单支持 kg/tx/wy/joox/bili/apple | 实际仅 **tx / wy** |
|
||||
| `README.md:21-26` | migu/qian/joox/apple/5sing 登录列标「cookie」 | 均**没有** `/login/{migu,qian,joox,apple,5sing}` 路由(与 P0-1 同类问题) |
|
||||
| `docs/API.md` | 与 README 同源的平台/接口描述 | 同样滞后(parse 支持平台、user-playlist 支持集等) |
|
||||
|
||||
---
|
||||
|
||||
## 六、环境与仓库卫生
|
||||
|
||||
- **6 个 MusicServer 实例在监听**:`16688`(常驻)、`16690`~`16693`、`16790`(测试泄漏,见 P1-4)。建议清理泄漏实例,`16688` 请确认是否为常用常驻服务后再决定。
|
||||
- **未追踪且会入库的目录**:`signer/`、`tools/`、`.workbuddy/`、`scripts/qs-local-login.mjs` 以及大量新增的 `sdk/**` 模块(5sing/apple/bili/joox/migu/qian/kw·kg·tx·wy 的 parse/playlist 等)。
|
||||
- `signer/server.mjs` 本身实现规范(强制 `QS_SIGNER_TOKEN`、设备号脱敏、敏感头不落盘),入库无碍;
|
||||
- `tools/mssdk-sign/` 含 `.exe` 与逆向笔记(`REVERSE.md`),**是否入库请自行判断**;
|
||||
- `.workbuddy/` 建议加进 `.gitignore`。
|
||||
- `.gitignore` 已正确覆盖:`node_modules/`、`sources/`、`data/`、`tmp/`、`*.log`、`soda-mssdk/`、`.env`。
|
||||
|
||||
---
|
||||
|
||||
## 七、建议修复顺序
|
||||
|
||||
1. **P0-1**(lr 登录)—— 一行导出 + 一个路由,成本极低,收益是解锁整个平台
|
||||
2. **P0-2**(migu 崩溃)—— 改 3 行错误处理 + 加全局兜底,消除远程可触发的进程崩溃
|
||||
3. **P1-4 / P1-5**(测试稳定性)—— 不修则后续每次 CI/回归都可能留下僵尸进程并产生假失败
|
||||
4. **P1-8 / 第五节文档**(文档同步)—— 顺手即可,避免误导
|
||||
5. **P0-3 / P1-7**(凭据存储)—— 视部署环境决定优先级
|
||||
6. P1-6、P2 各项按需清理
|
||||
|
||||
---
|
||||
|
||||
## 八、附:尚未逐条复核的线索
|
||||
|
||||
以下来自分区审查组的摘要,**本轮未逐条复核到行号**,建议作为下一轮排查入口。
|
||||
|
||||
**SDK 核心组**(`sdk/index.ts`、`sdk/types.ts`、`sdk/common/request.ts`、`sdk/auth/*`、`sdk/apis/*`):
|
||||
- wy 首次登录时账号资料获取失败(cookie 时序问题)
|
||||
- 请求取消语义失效;migu 候选链的多次探测请求不可取消
|
||||
- qs 解密时把整段音频读入内存(未走流式)
|
||||
- `Quality` 类型契约不一致:apple 实际返回 `preview`,但该值不在联合类型内
|
||||
- 各平台 `cancelHttp` 实现不一致;部分平台把 token 拼进 GET query
|
||||
|
||||
**SDK 平台模块组**(本轮已核实 4 条,见 P1-9 ~ P1-12)其余线索:
|
||||
- `sdk/migu/util.ts:375-378` 解密失败时把函数引用直接拼进异常消息
|
||||
- `sdk/joox/` 单曲接口未校验上游 `errorCode`(错误探针外泄、响应未解析)
|
||||
- `sdk/qian/util.ts:82` 附近三个导出函数名与 JSDoc 描述完全对调
|
||||
- `sdk/bili/util.ts` 内约 6 处结构相同的 AJAX 重试代码
|
||||
|
||||
**跨文件重复逻辑**:kw / kg / tx / wy 的 `parse.ts` 与 `playlist.ts` 存在大段相似实现,可考虑抽出公共解析工具。
|
||||
|
||||
> 报告前三节的 P0 与 P1-4 ~ P1-12 均有明确代码位置与实际验证依据(其中 P0-2 附最小复现脚本);本节线索建议复核后再动手。第四组「测试与文档」专项审查仍在进行,其领域(测试有效性、覆盖缺口、文档一致性)本报告已通过独立核对覆盖主要结论。
|
||||
@@ -4,9 +4,32 @@
|
||||
|
||||
## 功能
|
||||
|
||||
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)+ 有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr)(有声平台不参与聚合搜索)
|
||||
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(migu)、千千音乐(qian)、JOOX(joox)、哔哩哔哩(bili)、5sing;有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
|
||||
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
||||
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单
|
||||
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
|
||||
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
|
||||
|
||||
## 平台能力速查
|
||||
|
||||
| 平台 | id | 搜索 | 取流音质 | 歌词 | 封面 | 歌单 | 专辑 | 链接解析 | 登录 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 酷我音乐 | `kw` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | — |
|
||||
| 酷狗音乐 | `kg` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/短信/账密/token |
|
||||
| QQ音乐 | `tx` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/cookie |
|
||||
| 网易云 | `wy` | ✅ | 128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 列表/详情 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | 扫码/手机号 |
|
||||
| 汽水音乐 | `qs` | ✅ | 128k/320k(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
|
||||
| 咪咕音乐 | `migu` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
|
||||
| 千千音乐 | `qian` | ✅ | 64k/128k/320k/flac | ✅ | ✅ | ✅ 详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 曲目) |
|
||||
| JOOX | `joox` | ✅ | 128k(VIP 为试听片段) | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(VIP 完整曲) |
|
||||
| 哔哩哔哩 | `bili` | ✅ | 64k/128k/320k/flac/flac24bit(实际命中 B站档位 132k/192k;flac/Hi-Res 需大会员) | ✅ | ✅ | ✅ 搜索/详情(合集/多分P) | — | ✅ 歌曲/歌单 | 扫码 |
|
||||
| 5sing | `5sing` | ✅ | sq/hq/lq | ✅ | — | ✅ 搜索/详情 | — | ✅ 歌曲/歌单 | cookie(可选) |
|
||||
| Apple Music | `apple` | ✅ | preview 试听片段 | ✅(需登录) | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(media-user-token) |
|
||||
| 喜马拉雅 | `xm` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | cookie |
|
||||
| 猫耳FM | `me` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | token |
|
||||
| 蜻蜓FM | `qt` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | — |
|
||||
| 懒人听书 | `lr` | ✅ | 64k/128k | — | ✅ | — | ✅ 详情 | — | 账密 |
|
||||
|
||||
> 能力位以 `GET /sources` 返回为准(含 `albumSearch`/`playlist`/`parse` 等新增能力位)。
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -29,7 +52,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/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) |
|
||||
| `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`migu`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
|
||||
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
|
||||
| `GET /pic/:source/:id` | 获取封面 |
|
||||
| `GET /boards/:source` | 榜单列表 |
|
||||
@@ -39,7 +62,14 @@ pnpm typecheck # 仅类型检查,不运行
|
||||
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
|
||||
| `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
|
||||
| `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
|
||||
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx/xm) |
|
||||
| `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/migu/qian/joox/apple |
|
||||
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/migu/qian/joox/apple) |
|
||||
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/migu/qian/joox/bili/5sing/apple) |
|
||||
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple) |
|
||||
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
|
||||
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple;bili 支持合集/多分P) |
|
||||
| `POST /parse/song/:source` 等 | 链接解析:body `{ "link": "..." }`,三个端点 `parse/song` / `parse/album` / `parse/playlist`。支持 kw/kg/tx/wy/5sing/apple/bili/joox/qian/migu(其中 bili、5sing 无 album 端点) |
|
||||
| `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) |
|
||||
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
|
||||
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
|
||||
| `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) |
|
||||
@@ -49,10 +79,12 @@ 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** 扫码,登录后解锁完整曲目与无损取流) |
|
||||
| `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}` | 清除对应平台凭据(退出登录) |
|
||||
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
|
||||
| `GET /sources` | 平台列表(音质与能力) |
|
||||
| `GET /health` | 健康检查 |
|
||||
@@ -71,6 +103,12 @@ pnpm typecheck # 仅类型检查,不运行
|
||||
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
|
||||
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
|
||||
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
|
||||
- **migu**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/migu` 解密代理
|
||||
- **qian**:track id(T 开头,搜索结果的 `songmid`);免费曲目匿名可出 128k/320k/flac 明文直链,VIP/版权曲目需 cookie
|
||||
- **joox**:加密后的 track id(含 `=`/`+`,URL 编码后传);游客 128k,VIP 完整曲需 cookie(`wmid`+`session_key`)
|
||||
- **bili**:`<bvid>|<cid>`(搜索结果/合集曲目产出);合集/歌单 id 形如 `season:<id>:<mid>:<bvid>` 或 `bvid:<bvid>`
|
||||
- **5sing**:`<songId>|<songType>`(如 `932557|yc`,songType 为 yc 原创 / fc 翻唱 / bz 伴奏),URL 路径需编码 `|` 为 `%7C`
|
||||
- **apple**:数字歌曲 id(搜索结果产出);取流固定为 `preview` 试听片段(完整曲为 DRM,不在支持范围)
|
||||
|
||||
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
|
||||
|
||||
@@ -120,24 +158,32 @@ 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 抖音扫码、xm/me cookie 绑定)
|
||||
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 汽水扫码、
|
||||
bili 扫码、xm/me cookie 绑定、lr 账密)
|
||||
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
|
||||
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
|
||||
source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载)
|
||||
http/
|
||||
api-error.ts 业务错误类型(404/502 等 → 统一 JSON 响应)
|
||||
auth-gate.ts 调用秘钥鉴权(AUTH_KEY,全路径防扫描;/stream/* 可改用短时签名)
|
||||
rate-limit.ts per-IP 滑动窗口限流(RATE_LIMIT,默认 120/分钟)
|
||||
stream-sign.ts /stream/* 流地址短时签名(HMAC 派生,10 分钟有效)
|
||||
migu-stream.ts 咪咕加密流票据登记与逐块解密管道
|
||||
openapi/
|
||||
components.ts OpenAPI 共享 schema(与路由校验同源)
|
||||
ui.ts ReDoc 页面模板
|
||||
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy/qs 五平台)
|
||||
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;15 个平台)
|
||||
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
|
||||
apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy/qs)
|
||||
apis/ 各平台官方 URL 直连取流实现(15 平台)
|
||||
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/ 平台模块(搜索/歌词/榜单/歌单等;qs 含字节系参数签名与音频解密 crypto/)
|
||||
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 …
|
||||
test/
|
||||
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
|
||||
source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
|
||||
@@ -149,5 +195,10 @@ 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` 抖音扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
|
||||
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
|
||||
- **migu 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/migu` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
|
||||
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
|
||||
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
|
||||
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
|
||||
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
|
||||
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
|
||||
+93
-57
@@ -1,26 +1,47 @@
|
||||
# MusicServer 聚合音乐 API
|
||||
|
||||
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)。
|
||||
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
|
||||
|
||||
> 本文档为静态速查版。交互式文档以服务器实际输出为准:
|
||||
共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `migu`(咪咕)/
|
||||
`qian`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
|
||||
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
|
||||
|
||||
> **本文档是人工速查版,可能滞后于代码。**
|
||||
> 接口与参数以服务器自动生成的规范为准(路由 schema 同源,永远与实现同步):
|
||||
>
|
||||
> - **Swagger UI**:`http://localhost:16688/docs`(可直接调试)
|
||||
> - **ReDoc**:`http://localhost:16688/redoc`
|
||||
> - **OpenAPI 3.1**:`http://localhost:16688/openapi.json`(可生成各语言客户端)
|
||||
> - **Swagger UI**:`/docs`(可直接调试)
|
||||
> - **ReDoc**:`/redoc`
|
||||
> - **OpenAPI 3.1**:`/openapi.json`(可生成各语言客户端)
|
||||
> - **平台能力位**:`GET /sources` —— 各接口具体支持哪些平台,一律以它的能力位为准,本文不再硬列
|
||||
|
||||
## 约定
|
||||
|
||||
- 默认端口 **16688**(可用 `PORT` 环境变量修改)。
|
||||
- 仅支持 `GET` 方法;服务器支持 CORS(`Access-Control-Allow-Origin: *`)。
|
||||
- 默认端口 **16688**(`PORT` 环境变量可改)。
|
||||
- **全部接口为路径参数风格**,分页走 path 段且第 1 页可省略:`/search/kw/稻香`、`/search/kw/稻香/2`、
|
||||
`/album/kg/123/2`。查询参数只用于修饰(`?quality=`、`?limit=`、`?singer=` 等)。
|
||||
- 默认返回 `Access-Control-Allow-Origin: *`,可直接在浏览器/前端调用。
|
||||
- **成功**:`200` + `{"code": 0, "data": ...}`
|
||||
- **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}`
|
||||
- `422` 参数校验失败(缺失、非法音质、页码越界等,FastAPI 风格)
|
||||
- `404` 接口不存在 / 平台不支持该能力
|
||||
- `405` 方法不允许(响应带 `Allow` 头)
|
||||
- `502` 上游平台请求失败
|
||||
- `500` 服务器内部错误
|
||||
- 资源类接口统一为**路径参数风格**;`/search`、`/find` 搜索类接口使用 query 参数。
|
||||
- 平台歌曲 id 形态:kw/tx/wy 用 `songmid`,kg 用 `hash`。
|
||||
|
||||
| 状态码 | 含义 |
|
||||
| --- | --- |
|
||||
| `422` | 参数校验失败(Fastify/Ajv,FastAPI 风格) |
|
||||
| `404` | 接口不存在,或平台不支持该能力 |
|
||||
| `405` | 方法不允许(响应带 `Allow` 头) |
|
||||
| `502` | 上游平台请求失败 |
|
||||
| `500` | 服务器内部错误 |
|
||||
|
||||
### 鉴权与限流
|
||||
|
||||
- 设置 `AUTH_KEY` 后**全部路径**都要求 `X-Server-Key: <AUTH_KEY>`(含文档页与 404,防止扫描探测接口存在性)。
|
||||
- `/stream/*` 流代理例外:播放器无法携带自定义头,改用 `/url` 返回流地址时自动附带的 `?exp=&sig=` 短时签名(HMAC 派生,10 分钟有效)。
|
||||
- `RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭),超限返回 `429` + `Retry-After`。
|
||||
|
||||
### 歌曲 id 形态
|
||||
|
||||
各平台 id 约定(含 `kw` 的 songmid、`kg` 的 hash、`qs`/`xm`/`me` 的 id、`migu` 的三元组、
|
||||
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
|
||||
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
|
||||
|
||||
## 接口速查
|
||||
|
||||
@@ -29,70 +50,88 @@
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/health` | 健康检查(status / uptime / 平台数) |
|
||||
| GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList/singer/album) |
|
||||
| GET | `/sources` | 平台列表:音质与全部能力位(**判断某接口支持哪些平台的唯一依据**) |
|
||||
| GET | `/` | 302 重定向到 `/docs` |
|
||||
| GET | `/docs` | Swagger UI 文档页 |
|
||||
| GET | `/redoc` | ReDoc 阅读版文档 |
|
||||
| GET | `/openapi.json` | OpenAPI 3.1 规范文档 |
|
||||
| GET | `/docs` · `/redoc` · `/openapi.json` | Swagger UI / ReDoc / OpenAPI 3.1 规范 |
|
||||
|
||||
### 搜索
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台,失败的平台跳过 |
|
||||
| GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 |
|
||||
| GET | `/search/:source/:keyword[/:page]` | 搜索歌曲;`source` 为平台 id 或 `all`(聚合,自带 `noAggregate` 的平台不参与)。`?singer=` 附加歌手、`?limit=` 每页条数 |
|
||||
| GET | `/find/:source/:keyword` | 精确匹配搜索;`?singer=` `?albumName=` `?interval=` 辅助匹配,`source=all` 时 `?excludeSource=` 跳过平台 |
|
||||
|
||||
### 歌曲资源
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/url/:source/:id` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) |
|
||||
| GET | `/lyric/:source/:id` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) |
|
||||
| GET | `/pic/:source/:id` | 封面;可带 `albumId`(kg 需要) |
|
||||
| GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
|
||||
| GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `migu` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
|
||||
| GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
|
||||
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
|
||||
|
||||
### 榜单
|
||||
### 榜单 / 歌单 / 热搜
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/boards/:source` | 榜单列表 |
|
||||
| GET | `/boards/:source/:boardId` | 榜单详情;可带 `page`。`boardId` 兼容榜单列表的带前缀 `id`(`kw__93`)与裸 `bangid`(`93`) |
|
||||
|
||||
### 歌单 & 热搜
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/boards/:source/:boardId[/:page]` | 榜单详情(分页 path 段)。`boardId` 兼容带前缀 id(`kw__93`)与裸 `bangid`(`93`) |
|
||||
| GET | `/hot-search/:source` | 热搜词 |
|
||||
| GET | `/song-list/:source` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 |
|
||||
| GET | `/song-list/:source[/:page]` | 歌单列表;`?sortId=` `?tagId=` 各平台词汇不同,省略时用平台默认 |
|
||||
| GET | `/playlist/:source/:id[/:page]` | 歌单详情与曲目;`?limit=`。bili 支持合集/多分P |
|
||||
| GET | `/playlist/:source/search/:keyword` | 歌单搜索(`?page=` `?limit=`) |
|
||||
| GET | `/playlist/:source/categories` | 歌单分类列表 |
|
||||
| GET | `/playlist/:source/categories/:categoryId` | 分类下的歌单(`?page=` `?limit=`) |
|
||||
| GET | `/user-playlist/:source` | 个人歌单(需登录;仅 tx/wy 有完整实现,其余 404/502) |
|
||||
|
||||
### 歌手 & 专辑
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/singer/:source/:id` | 歌手信息(name/desc/avatar 与歌曲/专辑数量)。支持 kg/tx/wy |
|
||||
| GET | `/singer/:source/:id/songs` | 歌手歌曲列表;可带 `page`/`limit`(默认 20)。支持 kg/tx/wy |
|
||||
| GET | `/singer/:source/:id/albums` | 歌手专辑列表;可带 `page`/`limit`。支持 kg/tx/wy |
|
||||
| GET | `/album/:source/:id` | 专辑详情与曲目列表;可带 `page`/`limit`(limit 仅 kg 生效,kw 单页固定 1000 条)。支持 kg/kw/tx;tx 的专辑名/作者由曲目数据合成(desc 为空) |
|
||||
| GET | `/singer/:source/:id` | 歌手信息(name/desc/avatar 与歌曲/专辑数量) |
|
||||
| GET | `/singer/:source/:id/songs[/:page]` | 歌手歌曲列表;`?limit=` |
|
||||
| GET | `/singer/:source/:id/albums[/:page]` | 歌手专辑列表;`?limit=` |
|
||||
| GET | `/album/:source/:id[/:page]` | 专辑详情与曲目;`?limit=` |
|
||||
| GET | `/album/:source/search/:keyword` | 专辑搜索(`?page=` `?limit=`) |
|
||||
|
||||
歌手/专辑 id 来源:搜索结果条目包含 `albumId`;歌手 id 需从平台方获取(不在搜索结果中)。
|
||||
### 链接解析
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/parse/song/:source` | 解析歌曲网页链接 → 歌曲信息,body `{"link": "..."}` |
|
||||
| POST | `/parse/album/:source` | 解析专辑链接 → 专辑信息与曲目 |
|
||||
| POST | `/parse/playlist/:source` | 解析歌单链接 → 歌单信息与曲目 |
|
||||
|
||||
支持的平台见 `/sources` 的 `parse` 能力位;不支持的平台返回 404。
|
||||
|
||||
### 登录
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| POST | `/login/wy/qr` | 获取网易云扫码登录二维码(返回 `key`/`qrurl`/`qrimg`,`qrimg` 为 data URL 可直接展示) |
|
||||
| POST | `/login/wy/qr/check` | 轮询扫码状态,body 传 `{ "key": "..." }`。返回 `code`:800 过期 / 801 等待 / 802 已扫码 / 803 登录成功(自动保存凭据) |
|
||||
| POST | `/login/wy/cellphone` | 网易云手机号登录;body 传 `phone` + `password`(明文,服务端 MD5 后提交)或 `captcha`,可带 `countrycode`(默认 86) |
|
||||
| DELETE | `/login/wy` | 退出网易云登录 |
|
||||
| POST | `/login/kg` | 绑定酷狗 token;body 传 `{ "token": "...", "userid": ... }`(App 抓包获取),服务端向用户中心验证后存储 |
|
||||
| DELETE | `/login/kg` | 退出酷狗登录 |
|
||||
| POST | `/login/tx` | 绑定 QQ 音乐 cookie;body 传 `{ "cookie": "..." }`(需含 `uin` 与 `qm_keyst` 字段)。可选附 App 抓包的 `openId`/`accessToken`/`refreshToken`/`refreshKey` 供自动续期 |
|
||||
| DELETE | `/login/tx` | 退出 QQ 音乐登录 |
|
||||
| POST | `/login/refresh` | 手动触发 kg/tx 登录态续期(与自动任务同一逻辑;未登录平台跳过,单平台失败不影响其他) |
|
||||
| GET | `/login/status` | 登录状态(wy 实时校验 / kg、tx 已存凭据,含 `vipType` 与最近更新时间) |
|
||||
| POST | `/login/wy/qr` → `/login/wy/qr/check` | 网易云扫码(`check` body 传 `{"key": "..."}`) |
|
||||
| POST | `/login/wy/cellphone` | 网易云手机号登录(`password` 明文或 `captcha`) |
|
||||
| DELETE | `/login/wy` | 退出网易云 |
|
||||
| POST | `/login/kg/qr` → `/login/kg/qr/check` | 酷狗扫码 |
|
||||
| POST | `/login/kg/captcha` → `/login/kg/cellphone` | 酷狗短信验证码登录 |
|
||||
| POST | `/login/kg/password` | 酷狗账密登录(命中风控返回挑战,配合 `/login/kg/verify` 重试) |
|
||||
| POST | `/login/kg` | 绑定酷狗 token(App 抓包) |
|
||||
| DELETE | `/login/kg` | 退出酷狗 |
|
||||
| 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`) |
|
||||
| DELETE | `/login/lr` | 退出懒人听书 |
|
||||
| POST | `/login/bili/qr` → `/login/bili/qr/check` | 哔哩哔哩扫码(解锁大会员音质) |
|
||||
| DELETE | `/login/bili` | 退出哔哩哔哩 |
|
||||
| POST | `/login/refresh` | 手动触发登录态续期(kg/tx) |
|
||||
| GET | `/login/status` | 登录状态总览(各平台 `logged` 与账号资料) |
|
||||
|
||||
- 登录态持久化于 `data/auth.json`(不入库),登录后取流与平台请求自动携带凭据。
|
||||
- **登录态自动续期**:服务器启动 10s 后自动对 kg/tx 执行一次续期,此后每 24h 一次(kg 走 `login_by_token` AES/RSA 协议换新 token;tx 走 `music.login.LoginServer`,仅浏览器 cookie 时可能被网关拒绝,需在 `/login/tx` 附 App 抓包的完整凭据才可靠),结果输出到控制台日志。
|
||||
- VIP 歌曲完整播放地址暂不在支持范围;登录态目前用于提升请求稳定性与为后续能力预留。
|
||||
- 凭据持久化于 `data/auth.json`(不入库,文件权限 0600),登录后取流与平台请求自动携带。
|
||||
- 自动续期:启动 10s 后对 kg/tx 执行一次,此后每 24h 一次(音源模式下不参与取流,自动续期不启动)。
|
||||
|
||||
## 响应示例
|
||||
|
||||
@@ -103,15 +142,12 @@
|
||||
// GET /lyric/kw/228908
|
||||
{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "yrc": "" } }
|
||||
|
||||
// 参数缺失
|
||||
// 422
|
||||
{ "code": 422, "msg": "querystring must have required property 'name'", "detail": [...] }
|
||||
// 参数非法 → 422
|
||||
{ "code": 422, "msg": "params/source must be equal to one of the allowed values", "detail": [...] }
|
||||
|
||||
// 平台不支持该能力
|
||||
// 404
|
||||
// 平台不支持该能力 → 404
|
||||
{ "code": 404, "msg": "不支持的平台: xx" }
|
||||
|
||||
// 方法不允许
|
||||
// 405 + Allow: GET
|
||||
{ "code": 405, "msg": "方法 POST 不允许,请使用 GET" }
|
||||
// 方法不允许 → 405 + Allow 头
|
||||
{ "code": 405, "msg": "方法 POST 不允许,请使用 GET, HEAD" }
|
||||
```
|
||||
@@ -1,2 +1,4 @@
|
||||
# isolated-vm 的原生构建脚本需显式放行(pnpm 12 要求),
|
||||
# 否则加载失败会降级为「未安装 isolated-vm」,社区 .js 音源脚本被静默跳过。
|
||||
allowBuilds:
|
||||
isolated-vm: true
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env node
|
||||
// 汽水音乐「本机客户端登录」独立脚本 —— 不参与服务运行,需要时手动跑一次
|
||||
//
|
||||
// 做什么:官方 SodaMusic PC 客户端是 Chromium,登录态就存在本机
|
||||
// %APPDATA%\SodaMusic\Network\Cookies(SQLite)里,设备身份在 %APPDATA%\SodaMusic\DeviceV1
|
||||
// (gzip JSON,含 did/iid)。本脚本直接读出来写进 data/auth.json 的 qs 字段,
|
||||
// 免去扫码登录。参考实现:YYF1337/PopDownloader(SaKongA/PopDownloader 的 fork)。
|
||||
//
|
||||
// 用法:
|
||||
// node scripts/qs-local-login.mjs # 读取 + 校验 + 写入 data/auth.json
|
||||
// node scripts/qs-local-login.mjs --print # 只打印 sessionid,不落盘
|
||||
// node scripts/qs-local-login.mjs --no-verify # 跳过 /luna/pc/me 校验
|
||||
// node scripts/qs-local-login.mjs --dir <目录> # 指定客户端数据目录(默认 %APPDATA%\SodaMusic)
|
||||
// node scripts/qs-local-login.mjs --auth <文件> # 指定 auth.json(默认 <仓库>/data/auth.json)
|
||||
//
|
||||
// 校验需要官方签名模块(soda-mssdk/bdms.node 或 QS_MSSDK_DIR),没有就跳过校验只写入。
|
||||
// 注意:服务在跑时 auth.json 已缓存,写完需重启服务才生效(脚本会提示)。
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { gunzipSync } from 'node:zlib'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const flag = (name) => argv.includes(name)
|
||||
const value = (name) => {
|
||||
const i = argv.indexOf(name)
|
||||
return i >= 0 ? argv[i + 1] : ''
|
||||
}
|
||||
|
||||
const dataDir = resolve(value('--dir') || process.env.QS_SODA_DATA_DIR || join(process.env.APPDATA || '', 'SodaMusic'))
|
||||
const authPath = resolve(value('--auth') || join(root, 'data', 'auth.json'))
|
||||
const printOnly = flag('--print')
|
||||
const skipVerify = flag('--no-verify')
|
||||
const DEVICE_FILE = join(dataDir, 'DeviceV1')
|
||||
const COOKIE_DB = join(dataDir, 'Network', 'Cookies')
|
||||
|
||||
const die = (msg) => {
|
||||
console.error(`✗ ${msg}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// —— 1. 客户端设备身份(DeviceV1:gzip JSON 的 {did,iid})——
|
||||
const readDevice = () => {
|
||||
if (!existsSync(DEVICE_FILE)) return null
|
||||
const raw = readFileSync(DEVICE_FILE)
|
||||
let text
|
||||
try {
|
||||
text = gunzipSync(raw).toString('utf8')
|
||||
} catch {
|
||||
text = raw.toString('utf8')
|
||||
}
|
||||
const data = JSON.parse(text)
|
||||
return data?.did && data?.iid ? { did: String(data.did), iid: String(data.iid) } : null
|
||||
}
|
||||
|
||||
// —— 2. 客户端登录态(Chromium cookie 库,复制副本后只读查询)——
|
||||
const readSessionId = () => {
|
||||
if (!existsSync(COOKIE_DB)) die(`未找到客户端登录数据:${COOKIE_DB}\n 请先安装并登录官方汽水音乐 PC 客户端,或用 --dir 指定数据目录`)
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'qs-cookies-'))
|
||||
try {
|
||||
const copy = join(tempDir, 'Cookies')
|
||||
copyFileSync(COOKIE_DB, copy)
|
||||
for (const side of ['-wal', '-shm']) {
|
||||
if (existsSync(COOKIE_DB + side)) copyFileSync(COOKIE_DB + side, copy + side)
|
||||
}
|
||||
const { DatabaseSync } = require('node:sqlite')
|
||||
const db = new DatabaseSync(copy, { readOnly: true })
|
||||
try {
|
||||
const row = db.prepare(
|
||||
"SELECT value FROM cookies WHERE host_key IN ('.qishui.com', 'qishui.com') AND name = 'sessionid' LIMIT 1",
|
||||
).get()
|
||||
return String(row?.value ?? '').trim()
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
} catch (err) {
|
||||
if (/EBUSY|locked|busy|unable to open/i.test(String(err?.message))) {
|
||||
die('客户端正在运行且 cookie 库被占用,请退出汽水音乐客户端后重试')
|
||||
}
|
||||
die(`读取 cookie 库失败: ${err?.message ?? err}`)
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
// —— 3. 可选:签名打 /luna/pc/me 校验(status_code === 0 视为有效)——
|
||||
const findSigner = () => {
|
||||
const dirs = [process.env.QS_MSSDK_DIR, join(root, 'soda-mssdk')].filter(Boolean)
|
||||
for (const dir of dirs) {
|
||||
if (existsSync(join(dir, 'bdms.node'))) return join(dir, 'bdms.node')
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const verify = async (sessionid, device) => {
|
||||
const modulePath = findSigner()
|
||||
if (!modulePath) return null
|
||||
try {
|
||||
const bdms = require(modulePath)
|
||||
bdms.init({ deviceId: device.did })
|
||||
const query = new URLSearchParams({
|
||||
aid: '386088', app_name: 'luna_pc', region: 'cn', geo_region: 'cn', os_region: 'cn', sim_region: '',
|
||||
device_id: device.did, cdid: '', iid: device.iid, 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: device.did,
|
||||
})
|
||||
const url = `https://api.qishui.com/luna/pc/me?${query}`
|
||||
const headers = { 'user-agent': 'LunaPC/3.7.0(452316191)', cookie: `sessionid=${sessionid}` }
|
||||
const lines = Object.entries(headers).map(([k, v]) => `${k}\r\n${v}`).join('\r\n')
|
||||
const parts = String(bdms.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(t => t.trim())
|
||||
for (let i = 0; i + 1 < parts.length; i += 2) headers[parts[i]] = parts[i + 1]
|
||||
const res = await fetch(url, { headers })
|
||||
if (res.status !== 200) return null
|
||||
const body = await res.json().catch(() => null)
|
||||
const code = body?.status_code
|
||||
if (code === 0) return true
|
||||
return code == null ? null : false
|
||||
} catch (err) {
|
||||
console.log(`! 校验失败(按未校验处理): ${err?.message ?? err}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const device = readDevice()
|
||||
if (!device) console.log(`! 未读到客户端设备身份(${DEVICE_FILE}),校验时将跳过`)
|
||||
else console.log(`· 客户端设备: did=${device.did} iid=${device.iid}`)
|
||||
|
||||
const sessionid = readSessionId()
|
||||
if (!sessionid) die('客户端未登录(cookie 库中没有 .qishui.com 的 sessionid),请先在客户端登录')
|
||||
|
||||
let verified = null
|
||||
if (!skipVerify && device) {
|
||||
verified = await verify(sessionid, device)
|
||||
if (verified === false) die('客户端登录态已失效,请在汽水音乐客户端重新登录后再跑本脚本')
|
||||
}
|
||||
console.log(`· sessionid: ${sessionid.slice(0, 6)}…${sessionid.slice(-4)}(${sessionid.length} 字符)`)
|
||||
console.log(`· 校验: ${verified === true ? '有效' : verified === false ? '已失效' : skipVerify ? '已跳过(--no-verify)' : '无法判定(缺签名模块或网络异常)'}`)
|
||||
|
||||
if (printOnly) {
|
||||
console.log(sessionid)
|
||||
return
|
||||
}
|
||||
|
||||
const auth = existsSync(authPath) ? JSON.parse(readFileSync(authPath, 'utf8')) : {}
|
||||
auth.qs = { ...(auth.qs || {}), sessionid, cookie: `sessionid=${sessionid}`, source: 'local-client', updatedAt: new Date().toISOString() }
|
||||
writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`)
|
||||
console.log(`✓ 已写入 ${authPath}(qs.sessionid)`)
|
||||
console.log(' 服务在运行的话需要重启才生效(凭据已缓存);之后 /login/status 的 qs.logged 应为 true')
|
||||
}
|
||||
|
||||
main().catch(err => die(err?.message ?? String(err)))
|
||||
@@ -0,0 +1,50 @@
|
||||
// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块
|
||||
// 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。
|
||||
// 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/
|
||||
// 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。
|
||||
// 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、
|
||||
// 榜单/热搜/歌手/评论(上游均无)。
|
||||
// 取流入口在 sdk/apis/5sing.ts;songmid 约定 "<songId>|<songType>"(如 "932557|yc",与上游 ID 一致)。
|
||||
// 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益);
|
||||
// 本移植从 sdk/auth/store.ts 的 getCredentials('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。
|
||||
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import fivesingApi from '../apis/5sing.ts'
|
||||
import { buildSongLink, extractSongId, getStatus } from './util.ts'
|
||||
|
||||
const fivesing = {
|
||||
musicSearch,
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return fivesingApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
|
||||
getLyric(songInfo, ...rest) {
|
||||
return lyric.getLyric(songInfo, ...rest)
|
||||
},
|
||||
|
||||
playlist,
|
||||
parse,
|
||||
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
if (songInfo?.link) return String(songInfo.link)
|
||||
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id, songInfo?.songType)
|
||||
if (!songID || !songType) return 'http://5sing.kugou.com/'
|
||||
return buildSongLink(songType, songID)
|
||||
},
|
||||
|
||||
// 上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义
|
||||
userPlaylist: {
|
||||
async getList(_page?: number, _limit?: number): Promise<any> {
|
||||
throw new Error('user playlists not supported')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
/** 登录状态(凭据存在性判断;5sing 上游无扫码/账密登录,凭据只能手工写入 data/auth.json) */
|
||||
export const status = getStatus
|
||||
|
||||
export default fivesing
|
||||
@@ -0,0 +1,38 @@
|
||||
// 5sing 歌词(上游 fivesing/lyric.go 的 GetLyrics 移植)
|
||||
// 接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype=
|
||||
// → data.dynamicWords 即 LRC 文本(实测为 "[00:31.76]..." 格式,逐字/滚动歌词同源)。
|
||||
// 上游 ID 约定:model.Song.ID = "<songid>|<songtype>",Extra.songid/songtype 优先;
|
||||
// 本移植从 songmid 的 "|" 拆分(路由层只透传 songmid),也接受 songInfo.songType 字段。
|
||||
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric: '' }(5sing 无翻译歌词)。
|
||||
|
||||
import { isForeignSource, extractSongId } from './util.ts'
|
||||
import { fetchDynamicLyric } from './musicInfo.ts'
|
||||
|
||||
/** 非取消句柄版(测试/内部用):直接返回歌词文本 */
|
||||
export const getLyricText = async(songID: string, songType: string): Promise<string> => {
|
||||
const lyric = await fetchDynamicLyric(songID, songType)
|
||||
if (!lyric) throw new Error('lyrics not found')
|
||||
return lyric
|
||||
}
|
||||
|
||||
export const getLyric = (songInfo: any, ..._rest: any[]) => {
|
||||
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
|
||||
let current: any = null
|
||||
requestObj.cancelHttp = () => {
|
||||
current?.cancelHttp?.()
|
||||
current = null
|
||||
}
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
if (isForeignSource(songInfo)) throw new Error('source mismatch')
|
||||
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType)
|
||||
if (!songID) throw new Error('invalid id')
|
||||
const lyric = await fetchDynamicLyric(songID, songType, (request) => { current = request })
|
||||
if (!lyric) throw new Error('lyrics not found')
|
||||
return { lyric, tlyric: '' }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getLyric, getLyricText }
|
||||
@@ -0,0 +1,114 @@
|
||||
// 5sing 歌曲信息 / 取流底层(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink 移植)
|
||||
//
|
||||
// 链路(实测 2026-10-02 全部可用,无需签名/base64 参数——上游 fivesing 包内没有 _sign/base64 逻辑,
|
||||
// 仅 playlist 搜索的封面等是 CDN 地址;取流为明文 URL 直出):
|
||||
// 取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000
|
||||
// 详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords
|
||||
// 档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址(上游 getFirstValid 取首个非空)。
|
||||
// 上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。
|
||||
|
||||
import { MOBILE_API, fetch5singJson, getFirstValid, kindName, estimateDuration, buildSongLink } from './util.ts'
|
||||
|
||||
export type AudioHit = { type: string, url: string }
|
||||
|
||||
const QUALITY_TIERS: Array<'sq' | 'hq' | 'lq'> = ['sq', 'hq', 'lq']
|
||||
|
||||
/** data 里某档位的可用地址(主地址 → backup,上游 getFirstValid 语义) */
|
||||
const tierUrl = (data: any, tier: string): string =>
|
||||
getFirstValid(data?.[`${tier}url`], data?.[`${tier}url_backup`])
|
||||
|
||||
/**
|
||||
* 上游 fetchAudioLink:仅获取音频链接。
|
||||
* register 用于把当前 httpFetch 对象交给调用方(cancelHttp)。
|
||||
* preferLow:请求 64k/lq 时从低档位开始找(上游无该意识,移植补的最小档位选择,默认仍 sq 最高)。
|
||||
*/
|
||||
export const fetchAudioLink = async(
|
||||
songID: string,
|
||||
songType: string,
|
||||
register?: (request: any) => void,
|
||||
preferLow = false,
|
||||
): Promise<AudioHit> => {
|
||||
const url = `${MOBILE_API}/song/getSongUrl?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}`
|
||||
const { statusCode, body } = await fetch5singJson(url, { register })
|
||||
if (statusCode != 200 || !body || typeof body !== 'object') {
|
||||
throw new Error(`5sing 取流失败(HTTP ${statusCode})`)
|
||||
}
|
||||
if (Number(body.code) !== 1000) {
|
||||
// 实测 songtype 缺失/错误时 code=28 message=歌曲不存在
|
||||
throw new Error(body.message ? `5sing 取流失败(${body.message})` : 'api returned error code')
|
||||
}
|
||||
const data = body.data ?? {}
|
||||
const order = preferLow ? [...QUALITY_TIERS].reverse() : QUALITY_TIERS
|
||||
for (const tier of order) {
|
||||
const hit = tierUrl(data, tier)
|
||||
if (hit && /^https?:\/\//i.test(hit)) return { type: tier, url: hit }
|
||||
}
|
||||
throw new Error('no valid download url found')
|
||||
}
|
||||
|
||||
/** 上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象) */
|
||||
export const fetchNewGet = async(
|
||||
songID: string,
|
||||
songType: string,
|
||||
register?: (request: any) => void,
|
||||
): Promise<any> => {
|
||||
const url = `${MOBILE_API}/song/newget?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}`
|
||||
const { statusCode, body } = await fetch5singJson(url, { register })
|
||||
if (statusCode != 200 || !body || typeof body !== 'object') {
|
||||
throw new Error(`5sing 歌曲信息获取失败(HTTP ${statusCode})`)
|
||||
}
|
||||
return body.data ?? {}
|
||||
}
|
||||
|
||||
/** 上游 newget 歌词:data.dynamicWords(LRC 文本) */
|
||||
export const fetchDynamicLyric = async(
|
||||
songID: string,
|
||||
songType: string,
|
||||
register?: (request: any) => void,
|
||||
): Promise<string> => {
|
||||
const data = await fetchNewGet(songID, songType, register)
|
||||
return String(data?.dynamicWords ?? '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。
|
||||
* 取流失败即抛错(与上游 Parse 链路一致);newget 失败时保留兜底名 5sing_<type>_<id>。
|
||||
*/
|
||||
export const fetchSongInfo = async(songID: string, songType: string, register?: (request: any) => void) => {
|
||||
const hit = await fetchAudioLink(songID, songType, register)
|
||||
|
||||
let name = ''
|
||||
let artist = ''
|
||||
let cover = ''
|
||||
try {
|
||||
const data = await fetchNewGet(songID, songType, register)
|
||||
name = String(data?.SN ?? '')
|
||||
artist = String(data?.user?.NN ?? '')
|
||||
cover = String(data?.user?.I ?? '')
|
||||
} catch {
|
||||
// 上游 newget 失败时继续(metaBody 为 nil 不阻断)
|
||||
}
|
||||
if (!name) name = `5sing_${songType}_${songID}`
|
||||
|
||||
return {
|
||||
songmid: `${songID}|${songType}`,
|
||||
name,
|
||||
singer: artist,
|
||||
albumId: '',
|
||||
albumName: '',
|
||||
interval: 0,
|
||||
img: cover || null,
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
url: hit.url,
|
||||
type: hit.type,
|
||||
link: buildSongLink(songType, songID),
|
||||
songType,
|
||||
typeName: kindName(songType),
|
||||
source: '5sing' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式) */
|
||||
export const songInterval = (songSize: number): number => estimateDuration(songSize)
|
||||
@@ -0,0 +1,56 @@
|
||||
// 5sing 歌曲搜索(上游 fivesing/song.go 的 Search 移植)
|
||||
// 接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0
|
||||
// 返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。
|
||||
// 内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;上游以 typeEname 入 ID/Extra,
|
||||
// 本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "<songId>|<typeEname>")。
|
||||
// 上游 Search() 无 page 参数(硬编码 page=1);本移植按契约透传 page(实测 page 参数有效,pageInfo 可用于翻页)。
|
||||
// 时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。
|
||||
|
||||
import { formatPlayTime } from '../common/utils.ts'
|
||||
import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts'
|
||||
|
||||
/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing') */
|
||||
export const mapSearchSong = (item: any) => {
|
||||
const songType = String(item?.typeEname ?? '')
|
||||
const duration = estimateDuration(Number(item?.songSize) || 0)
|
||||
return {
|
||||
songmid: `${item?.songId ?? ''}|${songType}`,
|
||||
name: removeEmTags(unescapeHtml(item?.songName)),
|
||||
singer: removeEmTags(unescapeHtml(item?.singer ?? item?.nickName)),
|
||||
albumId: '',
|
||||
albumName: '',
|
||||
interval: duration > 0 ? formatPlayTime(duration) : 0,
|
||||
img: null,
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
// 统一种类字段(上游 typeEname / typeName)
|
||||
songType,
|
||||
typeName: String(item?.typeName ?? kindName(songType)),
|
||||
size: Number(item?.songSize) || 0,
|
||||
link: buildSongLink(songType, item?.songId),
|
||||
source: '5sing' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 20)
|
||||
const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5sing' as const }
|
||||
if (!word) return empty
|
||||
|
||||
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0`
|
||||
const { statusCode, body } = await fetch5singJson(url)
|
||||
if (statusCode != 200) throw new Error(`5sing 搜索失败(HTTP ${statusCode})`)
|
||||
|
||||
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
|
||||
const list = rawList.slice(0, pageSize).map(mapSearchSong)
|
||||
const pageInfo = body?.pageInfo ?? {}
|
||||
const total = Number(pageInfo.totalCount) || list.length
|
||||
// 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages
|
||||
const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo)
|
||||
return { list, allPage, total, limit: pageSize, source: '5sing' as const }
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,18 @@
|
||||
// 5sing 链接解析(上游 fivesing/song.go 的 Parse + playlist.go 的 ParsePlaylist 移植)
|
||||
// 歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetchSongInfo(直链 + newget 元数据,与上游一致:无直链即报错)
|
||||
// 歌单: /{userId}/dj/<id>.html → getPlaylistDetail 全量曲目(支持个性域名 userId,见 util.extractPlaylistLink)
|
||||
// 专辑: 上游无专辑概念(不实现)
|
||||
|
||||
import { extractSongLink } from './util.ts'
|
||||
import { fetchSongInfo } from './musicInfo.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
|
||||
export const parseSong = async(link: string) => {
|
||||
const parsed = extractSongLink(link)
|
||||
if (!parsed) throw new Error('invalid 5sing link')
|
||||
return fetchSongInfo(parsed.songID, parsed.songType)
|
||||
}
|
||||
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parsePlaylist }
|
||||
@@ -0,0 +1,251 @@
|
||||
// 5sing 歌单(上游 fivesing/fivesing.go + playlist.go 移植)
|
||||
// 搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/pictureUrl/playCount/userName/songCnt/content/userId
|
||||
// 上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植用同样回退、并发 5。
|
||||
// 详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN)
|
||||
// ② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析 <li class="p_rel"> 块(上游 parseSongsFromHTML)
|
||||
// ③ 数字 userId 页会 302 到个性域名页(如 /yeluoluo/dj/...),httpFetch 不跟跳 → util.fetch5sing 手动跟跳。
|
||||
// 分类/个人歌单:上游显式返回不支持(model.ErrPlaylistCategoriesUnsupported / ErrUserPlaylistsUnsupported),本移植保留同样语义(抛错)。
|
||||
// 链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名(/name/dj/xxx.html)与省略 user 段,ID 以 API 反查为准。
|
||||
|
||||
import { MOBILE_API, SEARCH_API, fetch5sing, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, buildPlaylistLink, extractPlaylistLink, kindName } from './util.ts'
|
||||
|
||||
/** 上游 GetPlaylistCategories / GetCategoryPlaylists:显式不支持 */
|
||||
export const getCategories = async() => {
|
||||
throw new Error('playlist categories not supported')
|
||||
}
|
||||
export const getCategoryPlaylists = async(_categoryId?: any, _page = 1, _limit = 30) => {
|
||||
throw new Error('playlist categories not supported')
|
||||
}
|
||||
|
||||
/** 歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状 */
|
||||
const mapPlaylistSong = (songID: string, kind: string, name: string, artist: string) => ({
|
||||
songmid: `${songID}|${kind}`,
|
||||
name,
|
||||
singer: artist,
|
||||
albumId: '',
|
||||
albumName: '',
|
||||
interval: 0,
|
||||
img: null,
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
songType: kind,
|
||||
typeName: kindName(kind),
|
||||
link: buildSongLink(kind, songID),
|
||||
source: '5sing' as const,
|
||||
})
|
||||
|
||||
/**
|
||||
* 上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。
|
||||
* 块正则 `<li class="p_rel">([\s\S]*?)</li>`;块内 href 提取 yc/fc/bz 与 id,s_soner 提取歌手(上游的拼写错误 soner 保留)。
|
||||
*/
|
||||
export const parseSongsFromHTML = (htmlContent: string) => {
|
||||
const blocks = [...String(htmlContent ?? '').matchAll(/<li class="p_rel">([\s\S]*?)<\/li>/g)]
|
||||
if (!blocks.length) throw new Error('no songs found in playlist html (structure mismatch)')
|
||||
|
||||
// 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对)
|
||||
const songRe = /href="(?:(?:https?:)?\/\/)?5sing\.kugou\.com\/(yc|fc|bz)\/(\d+)\.html"[^>]*>([^<]+)<\/a>/
|
||||
const artistRe = /class="s_soner[^"]*".*?>([^<]+)<\/a>/
|
||||
|
||||
const songs: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const match of blocks) {
|
||||
const blockHTML = match[1]
|
||||
const songMatch = songRe.exec(blockHTML)
|
||||
if (!songMatch) continue
|
||||
const kind = songMatch[1]
|
||||
const songID = songMatch[2]
|
||||
const rawName = songMatch[3]
|
||||
|
||||
let artist = 'Unknown'
|
||||
const artistMatch = artistRe.exec(blockHTML)
|
||||
if (artistMatch) artist = artistMatch[1]
|
||||
|
||||
const uniqueKey = `${kind}|${songID}`
|
||||
if (seen.has(uniqueKey)) continue
|
||||
seen.add(uniqueKey)
|
||||
|
||||
const name = removeEmTags(unescapeHtml(rawName)).trim()
|
||||
artist = removeEmTags(unescapeHtml(artist)).trim()
|
||||
songs.push(mapPlaylistSong(songID, kind, name, artist))
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
/** 上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名(data 必须是对象) */
|
||||
export const fetchCreatorName = async(playlistId: string): Promise<string> => {
|
||||
if (!playlistId) return ''
|
||||
try {
|
||||
const { statusCode, body } = await fetch5singJson(`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=user`)
|
||||
if (statusCode != 200 || !body || typeof body !== 'object') return ''
|
||||
const data = body.data
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return ''
|
||||
return String(data?.user?.NN ?? '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐) */
|
||||
const mapPlaylistSummary = (item: any) => {
|
||||
const id = String(item?.songListId ?? '')
|
||||
const userId = String(item?.userId ?? '')
|
||||
const desc = removeEmTags(unescapeHtml(item?.content))
|
||||
const creator = String(item?.userName ?? '') || (userId ? `ID: ${userId}` : '')
|
||||
return {
|
||||
id,
|
||||
name: removeEmTags(unescapeHtml(item?.title)),
|
||||
cover: String(item?.pictureUrl ?? ''),
|
||||
trackCount: Number(item?.songCnt) || 0,
|
||||
playCount: Number(item?.playCount) || 0,
|
||||
creator,
|
||||
creatorId: userId,
|
||||
// 上游怪癖:content 为字符串 "0" 时视为空简介
|
||||
description: desc === '0' ? '' : desc,
|
||||
link: buildPlaylistLink(userId, id),
|
||||
source: '5sing' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 SearchPlaylist(type=1),补创建者名(并发 5) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 20)
|
||||
const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const }
|
||||
if (!word) return empty
|
||||
|
||||
const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1`
|
||||
const { statusCode, body } = await fetch5singJson(url)
|
||||
if (statusCode != 200) throw new Error(`5sing 歌单搜索失败(HTTP ${statusCode})`)
|
||||
|
||||
const rawList: any[] = Array.isArray(body?.list) ? body.list : []
|
||||
const list = rawList.slice(0, pageSize).map(mapPlaylistSummary)
|
||||
|
||||
// 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx")
|
||||
const targets = list.filter((pl) => pl.creatorId && pl.creator === `ID: ${pl.creatorId}`)
|
||||
for (let i = 0; i < targets.length; i += 5) {
|
||||
await Promise.all(targets.slice(i, i + 5).map(async(pl) => {
|
||||
const name = await fetchCreatorName(pl.id)
|
||||
if (name) pl.creator = name
|
||||
}))
|
||||
}
|
||||
|
||||
const total = Number(body?.pageInfo?.totalCount) || list.length
|
||||
return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const }
|
||||
}
|
||||
|
||||
/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */
|
||||
export const fetchPlaylistAll = async(playlistIdRaw: string) => {
|
||||
const playlistId = String(playlistIdRaw ?? '').trim()
|
||||
if (!playlistId) throw new Error('invalid 5sing playlist id')
|
||||
|
||||
// 1) API 元数据(标题/封面/统计/UserID)
|
||||
const { statusCode, body } = await fetch5singJson(
|
||||
`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=ID,user`,
|
||||
)
|
||||
if (statusCode != 200) throw new Error(`fetch info failed: HTTP ${statusCode}`)
|
||||
|
||||
// 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错
|
||||
const data = body?.data
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
||||
throw new Error('playlist info not found or invalid (api returned empty list)')
|
||||
}
|
||||
|
||||
const userId = String(data?.user?.ID ?? '')
|
||||
if (!userId) throw new Error('playlist user not found or invalid id')
|
||||
|
||||
const playlist = {
|
||||
id: playlistId,
|
||||
name: String(data?.T ?? ''),
|
||||
cover: String(data?.P ?? ''),
|
||||
trackCount: Number(data?.E) || 0,
|
||||
playCount: Number(data?.H) || 0,
|
||||
creator: String(data?.user?.NN ?? ''),
|
||||
description: String(data?.C ?? ''),
|
||||
link: buildPlaylistLink(userId, playlistId),
|
||||
userId,
|
||||
source: '5sing' as const,
|
||||
}
|
||||
|
||||
// 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳)
|
||||
const page = await fetch5sing(playlist.link)
|
||||
if (page.statusCode != 200) throw new Error(`fetch html failed: HTTP ${page.statusCode}`)
|
||||
|
||||
// 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表)
|
||||
let songs: any[] = []
|
||||
try {
|
||||
songs = parseSongsFromHTML(String(page.body ?? ''))
|
||||
} catch {
|
||||
songs = []
|
||||
}
|
||||
return { playlist, songs }
|
||||
}
|
||||
|
||||
/** 歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E) */
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const { playlist, songs } = await fetchPlaylistAll(id)
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 50)
|
||||
const start = (pageNo - 1) * pageSize
|
||||
const list = songs.slice(start, start + pageSize)
|
||||
const total = songs.length
|
||||
return {
|
||||
source: '5sing' as const,
|
||||
info: {
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
img: playlist.cover,
|
||||
desc: playlist.description,
|
||||
author: playlist.creator,
|
||||
link: playlist.link,
|
||||
trackCount: playlist.trackCount,
|
||||
playCount: playlist.playCount,
|
||||
},
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total,
|
||||
allPage: Math.ceil(total / pageSize) || 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistSongs:仅返回曲目列表(全量) */
|
||||
export const getPlaylistSongs = async(id: string) => {
|
||||
const { songs } = await fetchPlaylistAll(id)
|
||||
return songs
|
||||
}
|
||||
|
||||
/** 上游 ParsePlaylist:歌单链接 → 详情(全量曲目,匹配上游返回全部歌曲的行为) */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const parsed = extractPlaylistLink(link)
|
||||
if (!parsed) throw new Error('invalid 5sing playlist link')
|
||||
const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId)
|
||||
return {
|
||||
source: '5sing' as const,
|
||||
info: {
|
||||
id: playlist.id,
|
||||
name: playlist.name,
|
||||
img: playlist.cover,
|
||||
desc: playlist.description,
|
||||
author: playlist.creator,
|
||||
link: playlist.link,
|
||||
trackCount: playlist.trackCount,
|
||||
playCount: playlist.playCount,
|
||||
},
|
||||
list: songs,
|
||||
page: 1,
|
||||
limit: songs.length || 1,
|
||||
total: songs.length,
|
||||
allPage: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植)
|
||||
// 平台 id 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing";
|
||||
// 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。
|
||||
// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页),
|
||||
// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
|
||||
|
||||
/** 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302) */
|
||||
export const MOBILE_API = 'http://mobileapi.5sing.kugou.com'
|
||||
/** 搜索接口(type=0 单曲 / type=1 歌单) */
|
||||
export const SEARCH_API = 'http://search.5sing.kugou.com/home/json'
|
||||
|
||||
/** 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏) */
|
||||
export const SONG_KINDS: Record<string, string> = {
|
||||
yc: '原创',
|
||||
fc: '翻唱',
|
||||
bz: '伴奏',
|
||||
}
|
||||
|
||||
/** 上游 getFirstValid:返回第一个非空字符串(空串视为无效) */
|
||||
export const getFirstValid = (...urls: Array<string | null | undefined>): string => {
|
||||
for (const u of urls) {
|
||||
if (u) return String(u)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 上游 removeEmTags:去掉搜索结果里 <em class="keyword"> 高亮标签 */
|
||||
export const removeEmTags = (s: any): string =>
|
||||
String(s ?? '')
|
||||
.replaceAll('<em class="keyword">', '')
|
||||
.replaceAll('</em>', '')
|
||||
.trim()
|
||||
|
||||
const HTML_ENTITIES: Record<string, string> = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
nbsp: ' ',
|
||||
copy: '©',
|
||||
reg: '®',
|
||||
hellip: '…',
|
||||
mdash: '—',
|
||||
ndash: '–',
|
||||
}
|
||||
|
||||
/** Go html.UnescapeString 的等价实现(常用实体 + 数字实体) */
|
||||
export const unescapeHtml = (s: any): string =>
|
||||
String(s ?? '').replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => {
|
||||
if (entity.startsWith('#x') || entity.startsWith('#X')) {
|
||||
const code = Number.parseInt(entity.slice(2), 16)
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
||||
}
|
||||
if (entity.startsWith('#')) {
|
||||
const code = Number.parseInt(entity.slice(1), 10)
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
||||
}
|
||||
return HTML_ENTITIES[entity.toLowerCase()] ?? match
|
||||
})
|
||||
|
||||
/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */
|
||||
export const get5singCookie = (): string => String((getCredentials('5sing') as { cookie?: string } | null)?.cookie ?? '')
|
||||
|
||||
/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */
|
||||
export const getStatus = () => {
|
||||
const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null
|
||||
return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
|
||||
}
|
||||
|
||||
/** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */
|
||||
export const isForeignSource = (songInfo: any): boolean =>
|
||||
Boolean(songInfo?.source) && songInfo.source !== '5sing'
|
||||
|
||||
/**
|
||||
* 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现)
|
||||
* httpFetch 不跟随 302:这里手动跟跳最多 3 次(5sing 老链接 http→https / 数字 userId→个性域名)。
|
||||
*/
|
||||
export const fetch5sing = async(
|
||||
url: string,
|
||||
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
|
||||
): Promise<any> => {
|
||||
const cookie = get5singCookie()
|
||||
const headers = {
|
||||
'User-Agent': UserAgent,
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
...(options.headers || {}),
|
||||
}
|
||||
let current = String(url)
|
||||
let response: any = null
|
||||
for (let hop = 0; hop <= 3; hop++) {
|
||||
const request = httpFetch(current, { headers, timeout: options.timeout ?? 15000 })
|
||||
options.register?.(request)
|
||||
response = await request.promise
|
||||
const status = Number(response?.statusCode) || 0
|
||||
const location = response?.headers?.location
|
||||
if (status >= 300 && status < 400 && location) {
|
||||
try {
|
||||
current = new URL(String(location), current).href
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
continue
|
||||
}
|
||||
return response
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* 5sing 手机端 API 有频率控制(实测连续请求会返回 { code: 13, message: "操作太频繁", data: [] }),
|
||||
* 上游 Go 无重试;本移植对 JSON 接口做最多 2 次退避重试,保证搜索/取流/歌词/歌单在突发请求下稳定。
|
||||
*/
|
||||
export const fetch5singJson = async(
|
||||
url: string,
|
||||
options: { headers?: Record<string, any>, timeout?: number, register?: (request: any) => void } = {},
|
||||
): Promise<any> => {
|
||||
let response: any = null
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
response = await fetch5sing(url, options)
|
||||
const code = Number(response?.body?.code)
|
||||
const message = String(response?.body?.message ?? '')
|
||||
if ((code === 13 || message.includes('太频繁')) && attempt < 2) {
|
||||
await sleep(800 * (attempt + 1))
|
||||
continue
|
||||
}
|
||||
return response
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
/** 歌曲页链接(上游 Link 拼法:http://5sing.kugou.com/{typeEname}/{songId}.html) */
|
||||
export const buildSongLink = (songType: string, songId: string | number): string =>
|
||||
songType && songId ? `http://5sing.kugou.com/${songType}/${songId}.html` : ''
|
||||
|
||||
/** 歌单页链接(上游 Link 拼法:http://5sing.kugou.com/{userId}/dj/{playlistId}.html) */
|
||||
export const buildPlaylistLink = (userId: string | number, playlistId: string): string =>
|
||||
userId && playlistId ? `http://5sing.kugou.com/${userId}/dj/${playlistId}.html` : ''
|
||||
|
||||
/**
|
||||
* 解析歌曲的 <songid>|<songtype> 约定(上游 model.Song.ID: "123|yc" + Extra{songid,songtype})
|
||||
* 兼容只给了 songType 字段(路由层只透传 songmid 时用 `|` 分隔)。
|
||||
*/
|
||||
export const extractSongId = (
|
||||
songmid: any,
|
||||
songTypeFallback?: any,
|
||||
): { songID: string, songType: string } => {
|
||||
const raw = String(songmid ?? '')
|
||||
const [idPart = '', typePart = ''] = raw.split('|')
|
||||
return {
|
||||
songID: idPart || '',
|
||||
songType: String(typePart || songTypeFallback || ''),
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 Parse 的正则:5sing.kugou.com/(\w+)/(\d+).html(专辑/歌手页不匹配) */
|
||||
export const extractSongLink = (link: any): { songType: string, songID: string } | null => {
|
||||
const match = /5sing\.kugou\.com\/(\w+)\/(\d+)\.html/.exec(String(link ?? ''))
|
||||
if (!match) return null
|
||||
return { songType: match[1], songID: match[2] }
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游 ParsePlaylist 的正则增强:
|
||||
* 原正则 `5sing\.kugou\.com/(?:(\d+)/)?dj/([a-zA-Z0-9]+)\.html` 只认数字 userId,
|
||||
* 个性域名(如 /yeluoluo/dj/xxx.html)与省略 user 段的链接会漏;userId 本身不参与请求(API 反查更准),故放宽为任意单段。
|
||||
*/
|
||||
export const extractPlaylistLink = (link: any): { userId: string, playlistId: string } | null => {
|
||||
const match = /5sing\.kugou\.com\/(?:([^/?#]+)\/)?dj\/([a-zA-Z0-9]+)\.html/.exec(String(link ?? ''))
|
||||
if (!match) return null
|
||||
return { userId: match[1] || '', playlistId: match[2] }
|
||||
}
|
||||
|
||||
/** 上游 Search 的时长估算:songSize*8/320000(320kbps 假设;songSize 为 0 时返回 0) */
|
||||
export const estimateDuration = (songSize: number): number => {
|
||||
const size = Number(songSize) || 0
|
||||
return size > 0 ? Math.trunc((size * 8) / 320000) : 0
|
||||
}
|
||||
|
||||
/** 作品类型中文名(typeName 缺失时兜底) */
|
||||
export const kindName = (songType: string): string => SONG_KINDS[songType] ?? ''
|
||||
@@ -0,0 +1,53 @@
|
||||
// 5sing 取流(上游 fivesing/download.go 的 GetDownloadURL + fivesing.go 的 fetchAudioLink 移植)
|
||||
//
|
||||
// 链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=<id>&songtype=<yc|fc|bz>
|
||||
// → { code: 1000, data: { squrl/squrl_backup, hqurl/hqurl_backup, lqurl/lqurl_backup, sqsize/hqsize/lqsize } }
|
||||
// 直链为 CDN 明文地址(wsaudiobssdlbig.kugou.com,https,无需 Referer/防盗链签名;实测 Range 206 可播)。
|
||||
// 说明:上游 fivesing 包内没有 base64/加密参数逻辑(不存在 _sign/song_id 签名),取流就是上述明文 GET;
|
||||
// 数字 userId 的网页链接 302 的问题只影响歌单页,与取流无关。
|
||||
// 档位:上游固定 sq(最高,常为无损/高码)→ hq → lq,返回首个非空(含 _backup);
|
||||
// 本移植默认沿用该顺序,只有显式请求 64k(或 type=lq)时才倒序优先 lq,返回实际命中档位。
|
||||
// songmid 约定 "<songId>|<songType>";Parse 出来的歌曲带 url 时直接复用(上游 GetDownloadURL: if s.URL != "" return)。
|
||||
|
||||
import { fetchAudioLink } from '../5sing/musicInfo.ts'
|
||||
import { extractSongId, isForeignSource } from '../5sing/util.ts'
|
||||
|
||||
/** 上游只有 sq/hq/lq 三档,实际命中档位以返回 type 为准(sq 常缺,多命中 hq) */
|
||||
export const supportQualitys = ['sq', 'hq', 'lq']
|
||||
|
||||
/** 上游 GetDownloadURL 的等价实现(非取消句柄版):register 用于取消当前请求 */
|
||||
export const getDownloadUrl = async(songInfo: any, type?: string, register?: (request: any) => void) => {
|
||||
if (isForeignSource(songInfo)) throw new Error('source mismatch')
|
||||
// 上游逻辑:Song.URL 已带直链(Parse 场景)直接复用
|
||||
if (songInfo?.url) {
|
||||
return { type: String(songInfo?.type || type || 'sq'), url: String(songInfo.url) }
|
||||
}
|
||||
const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType)
|
||||
if (!songID || !songType) {
|
||||
throw new Error('invalid id structure: 5sing songmid 需为 "<songId>|<songType>"(songType 为 yc/fc/bz)')
|
||||
}
|
||||
const preferLow = type === '64k' || type === 'lq'
|
||||
return fetchAudioLink(songID, songType, register, preferLow)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一取流接口:getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
* promise resolve → { type: 'sq'|'hq'|'lq', url }(type 为实际命中档位,可能低于请求档位)
|
||||
*/
|
||||
export const getMusicUrl = (songInfo: any, type?: string) => {
|
||||
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
|
||||
let current: any = null
|
||||
requestObj.cancelHttp = () => {
|
||||
current?.cancelHttp?.()
|
||||
current = null
|
||||
}
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
const hit = await getDownloadUrl(songInfo, type, (request) => { current = request })
|
||||
return { type: hit.type, url: hit.url }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl, getDownloadUrl, supportQualitys }
|
||||
@@ -0,0 +1,72 @@
|
||||
// Apple Music 取流(上游 apple.go GetDownloadURL)
|
||||
// ⚠️ Apple 只提供 **preview 试听片段**(audio-ssl.itunes.apple.com,AAC/m4a,约 30-90 秒,实测单文件 ~3MB),
|
||||
// 完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密,
|
||||
// 本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,并在注释/响应里明确标注,不假装是无损完整曲。
|
||||
// 歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用,同上游 s.URL 逻辑)。
|
||||
// 统一接口约定:apis('apple').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type:'preview', url, ext:'m4a', preview:true, songDuration, isrc }
|
||||
// (songDuration 是歌曲完整时长,试听片段本身约 30-90 秒)
|
||||
// type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐
|
||||
// /sources 与 /url?quality 枚举,返回的实际档位以 type='preview' 为准。
|
||||
import { appleAmpGetTracked, extractAppleID, getAppleConfig } from '../apple/util.ts'
|
||||
|
||||
export const supportQualitys = ['128k']
|
||||
|
||||
const isAudioURL = (value: any): boolean => /^https?:/i.test(String(value ?? ''))
|
||||
|
||||
/** 详情 → preview 结果(内部/测试用) */
|
||||
export const previewFromAttributes = (attr: any) => {
|
||||
const url = String(attr?.previews?.[0]?.url ?? '')
|
||||
if (!url) {
|
||||
throw new Error('apple music: no preview URL available (完整曲目受 DRM 保护,SDK 仅提供 30-90 秒试听片段)')
|
||||
}
|
||||
return {
|
||||
type: 'preview',
|
||||
url,
|
||||
ext: 'm4a',
|
||||
preview: true,
|
||||
// 注意:这是歌曲完整时长(catalog durationInMillis),不是试听片段长度——试听片段约 30-90 秒
|
||||
songDuration: Math.round(Number(attr?.durationInMillis ?? 0) / 1000),
|
||||
isrc: attr?.isrc || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 按 song id 拉取 preview(上游 GetDownloadURL → Parse) */
|
||||
export const getPreviewUrl = async(songId: string, tracker?: { current: any }) => {
|
||||
const cfg = getAppleConfig()
|
||||
const body: any = await appleAmpGetTracked(
|
||||
`/v1/catalog/${cfg.storefront}/songs/${songId}`,
|
||||
{ extend: 'extendedAssetUrls' },
|
||||
tracker,
|
||||
)
|
||||
const data = body?.data ?? []
|
||||
if (!data.length) throw new Error(`apple song not found: ${songId}`)
|
||||
return previewFromAttributes(data[0]?.attributes)
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo: any, _type?: string) => {
|
||||
const requestObj: any = { promise: null as any, cancelHttp() {} }
|
||||
const tracker: { current: any } = { current: null }
|
||||
requestObj.cancelHttp = () => {
|
||||
try {
|
||||
tracker.current?.cancelHttp?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
tracker.current = null
|
||||
}
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
// 上游 GetDownloadURL:Song.URL 已有直链时直接复用(搜索结果里的 previewUrl / 链接解析结果)
|
||||
if (isAudioURL(songInfo?.previewUrl)) return { type: 'preview', url: String(songInfo.previewUrl), ext: 'm4a', preview: true }
|
||||
if (isAudioURL(songInfo?.url)) return { type: 'preview', url: String(songInfo.url), ext: 'm4a', preview: true }
|
||||
|
||||
const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
|
||||
if (!songId) throw new Error('apple 歌曲 id 无效(需要数字 song id 或 Apple Music 歌曲链接)')
|
||||
return getPreviewUrl(songId, tracker)
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl, getPreviewUrl, previewFromAttributes, supportQualitys }
|
||||
@@ -0,0 +1,158 @@
|
||||
// Bilibili 取流(上游 bilibili/bilibili.go 的 fetchAudioURL + account.go 的 IsVipAccount)
|
||||
// 接口: https://api.bilibili.com/x/player/playurl?fnval=&qn=127&bvid=&cid=
|
||||
// fnval=80 标准 DASH(匿名可用)
|
||||
// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点)
|
||||
// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底;
|
||||
// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。
|
||||
// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用),
|
||||
// 失败时回退原始地址,保证客户端仍能自行跟跳。
|
||||
// 参考 Referer/UA 必须携带,否则 CDN 可能 403。
|
||||
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts'
|
||||
|
||||
// 与 routes.ts 的 QUALITIES 枚举对齐(132k/192k 为 B 站实际档位,命中时按实际返回)
|
||||
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
||||
|
||||
const QUALITY_LABEL: Record<number, string> = {
|
||||
30216: '64k',
|
||||
30232: '132k',
|
||||
30280: '192k',
|
||||
30250: 'flac',
|
||||
30251: 'flac24bit',
|
||||
}
|
||||
|
||||
let vipCache: { value: boolean, at: number } | null = null
|
||||
|
||||
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
|
||||
export const resetVipCache = () => {
|
||||
vipCache = null
|
||||
}
|
||||
|
||||
/** 上游 IsVipAccount:x/web-interface/nav 的 isLogin + vipStatus + vipType(进程内缓存 5 分钟) */
|
||||
export const isVipAccount = async(cookie: string): Promise<boolean> => {
|
||||
if (!cookie) return false
|
||||
if (vipCache && Date.now() - vipCache.at < 5 * 60 * 1000) return vipCache.value
|
||||
try {
|
||||
const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', {
|
||||
headers: biliHeaders(cookie),
|
||||
timeout: 15000,
|
||||
}).promise as any
|
||||
const data = body?.data ?? {}
|
||||
const vip = body?.code === 0 && !!data.isLogin && Number(data.vipStatus) === 1 && Number(data.vipType) > 0
|
||||
vipCache = { value: vip, at: Date.now() }
|
||||
return vip
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const pickAudio = (data: any, type: string): { id: number, url: string, label: string } | null => {
|
||||
const dash = data?.dash ?? {}
|
||||
const flac = dash?.flac?.audio
|
||||
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? { id: 30251, url: String(flac.baseUrl) } : null
|
||||
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
|
||||
const standards: any[] = (dash?.audio ?? [])
|
||||
.filter((a: any) => a?.baseUrl)
|
||||
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
|
||||
|
||||
if (type === '64k') {
|
||||
const low = standards.find(a => Number(a.id) === 30216)
|
||||
if (low) return { id: 30216, url: String(low.baseUrl), label: QUALITY_LABEL[30216] }
|
||||
}
|
||||
|
||||
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
|
||||
if (hires) return { ...hires, label: QUALITY_LABEL[30251] }
|
||||
if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] }
|
||||
if (standards.length) {
|
||||
const best = standards[0]
|
||||
return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` }
|
||||
}
|
||||
const durl = data?.durl?.[0]?.url
|
||||
if (durl) return { id: 0, url: String(durl), label: 'durl' }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */
|
||||
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => {
|
||||
const cookie = await getCookie()
|
||||
const needFlac = type === 'flac' || type === 'flac24bit'
|
||||
const vip = await isVipAccount(cookie)
|
||||
const fnval = needFlac || vip ? 4048 : 80
|
||||
const url = `https://api.bilibili.com/x/player/playurl?fnval=${fnval}&qn=127&bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(String(cid))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: biliHeaders(cookie), timeout: 15000 }).promise as any
|
||||
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}` : ''})`)
|
||||
const picked = pickAudio(body.data, type)
|
||||
if (!picked) throw new Error('B站未返回可用音频流')
|
||||
return { type: picked.label, url: picked.url }
|
||||
}
|
||||
|
||||
/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */
|
||||
const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise<string> => {
|
||||
let current = String(url)
|
||||
for (let hop = 0; hop < 3; hop++) {
|
||||
let response: any
|
||||
try {
|
||||
const request = httpFetch(current, {
|
||||
method: 'get',
|
||||
headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' },
|
||||
timeout: 15000,
|
||||
})
|
||||
if (tracker) tracker.current = request
|
||||
response = await request.promise
|
||||
} catch {
|
||||
return current
|
||||
}
|
||||
const statusCode = Number(response?.statusCode) || 0
|
||||
const location = response?.headers?.location
|
||||
if (statusCode >= 300 && statusCode < 400 && location) {
|
||||
try {
|
||||
current = new URL(String(location), current).href
|
||||
continue
|
||||
} catch {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
|
||||
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
|
||||
*/
|
||||
export const getMusicUrl = (songInfo: any, type: string) => {
|
||||
const tracker: { current: any } = { current: null }
|
||||
const requestObj: any = {
|
||||
cancelHttp() {
|
||||
try {
|
||||
tracker.current?.cancelHttp?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
promise: null as any,
|
||||
}
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
const { bvid, cid } = parseSongId(songInfo?.songmid ?? songInfo?.id)
|
||||
if (!bvid) throw new Error('bili songmid 需为 <bvid>|<cid> 格式')
|
||||
let realCid = cid
|
||||
if (!realCid) {
|
||||
const view = await fetchView(bvid)
|
||||
realCid = String(view?.pages?.[0]?.cid ?? '')
|
||||
}
|
||||
if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)')
|
||||
const info = await getAudioUrl(bvid, realCid, type)
|
||||
const cookie = await getCookie()
|
||||
const finalUrl = await followRedirects(info.url, cookie, tracker)
|
||||
return { type: info.type, url: finalUrl }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
@@ -0,0 +1,49 @@
|
||||
// JOOX URL 获取(新版 web 播放器通道 openjoox2,含 md5(secretAPIKey) 签名)
|
||||
// 链路:GET cache.api.joox.com/openjoox2/v1/track/<songid>?...&secret=<md5>
|
||||
// → play_url_list[0](无则 refrain_url)→ 带 vkey/hdnts 的 CDN 直链(ak-hk.stream.music.joox.com 等)
|
||||
// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10-02 实测),
|
||||
// 故取流改用官网当前使用的 openjoox2 通道(签名细节见 sdk/joox/musicInfo.ts)。
|
||||
// 地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0,登录 cookie 的
|
||||
// wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。
|
||||
// 实测:游客态返回 128k MP3(免费完整曲目)或 VIP 曲目的 preview/refrain 片段;
|
||||
// 未登录请求 VIP 曲目时 play_url_list 为试听片段——与上游行为一致(上游同样只能拿游客流)。
|
||||
// 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { jooxHeaders, normalizeJooxID } from '../joox/util.ts'
|
||||
import { buildTrackUrl } from '../joox/musicInfo.ts'
|
||||
|
||||
/** 游客通道实测仅 128k MP3;VIP 账号理论上更高,但需登录后验证,故不虚报 */
|
||||
export const supportQualitys = ['128k']
|
||||
|
||||
export const getMusicUrl = (songInfo: any, _type?: string) => {
|
||||
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
|
||||
let current: any = null
|
||||
requestObj.cancelHttp = () => {
|
||||
if (current?.cancelHttp) current.cancelHttp()
|
||||
}
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '')
|
||||
if (!songId) throw new Error('joox 歌曲 id 无效')
|
||||
|
||||
current = httpFetch(buildTrackUrl(songId, { fullSong: true }), { headers: jooxHeaders(), timeout: 20000 })
|
||||
const { statusCode, body } = await current.promise as any
|
||||
if (statusCode != 200 || !body || typeof body !== 'object' || body.code) {
|
||||
throw new Error(`joox 取流失败:${body?.msg || body?.message || `HTTP ${statusCode}`}`)
|
||||
}
|
||||
|
||||
const urls: any[] = Array.isArray(body.play_url_list) ? body.play_url_list : []
|
||||
const clean = (value: any) => String(value ?? '').replace(/&/g, '&')
|
||||
let url = urls.map(clean).find(item => item.startsWith('http')) || ''
|
||||
if (!url && clean(body.refrain_url).startsWith('http')) url = clean(body.refrain_url)
|
||||
if (!url) throw new Error('无可用播放地址(VIP/版权限制或地区不可用)')
|
||||
|
||||
return { type: '128k', url }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl }
|
||||
+8
-2
@@ -15,6 +15,7 @@
|
||||
// 不匹配视为未命中、沿降级链换档重试;
|
||||
// - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合,
|
||||
// 本组合已验证为真音频。
|
||||
// - 返回 url 的 query 用 `$` 顶替 `=`,统一还原后对外返回(见 normalizeUrl)。
|
||||
// 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
|
||||
// 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发),
|
||||
// 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法,
|
||||
@@ -48,6 +49,11 @@ const fallbackChain: Record<string, string[]> = {
|
||||
// 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播
|
||||
const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a'])
|
||||
|
||||
// 酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`,实为酷我客户端
|
||||
// 自有写法,签名 token 在 path 里、query 无 sign 参数),`$` 非标准分隔符,播放器与 URL
|
||||
// 解析器普遍不认,统一还原成 `=` 再对外返回
|
||||
const normalizeUrl = (url: string) => url.replace(/\$/g, '=')
|
||||
|
||||
// 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality)
|
||||
export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit']
|
||||
|
||||
@@ -68,7 +74,7 @@ const getUrlByMobi = (songmid: string, tier: { br: string, bitrate: number }): P
|
||||
const data = body?.data
|
||||
if (statusCode != 200 || !data?.url) return null
|
||||
if (Number(data.bitrate) != tier.bitrate || !plainFormats.has(String(data.format))) return null
|
||||
return { url: data.url }
|
||||
return { url: normalizeUrl(data.url) }
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
@@ -156,7 +162,7 @@ const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => {
|
||||
return Promise.reject(new Error(msg || '获取失败'))
|
||||
}
|
||||
// 服务器无视 br,匿名实际只回 128k,如实上报
|
||||
return { type: '128k', url: body.data.url }
|
||||
return { type: '128k', url: normalizeUrl(body.data.url) }
|
||||
})
|
||||
return requestObj
|
||||
}
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// 咪咕取流 —— listen-url 多档位候选链 + 逐档探测(Range 0-63 验音频魔数/解密验证),
|
||||
// 命中可访问的最高档位;匿名仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie。
|
||||
// 上游对应:music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
|
||||
// selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
AndroidChannel, AndroidUA, AndroidVersion, buildUrl, decryptZ3D, firstNonEmpty,
|
||||
getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem,
|
||||
} from '../migu/util.ts'
|
||||
|
||||
export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
||||
|
||||
/** 平台档位 → 咪咕 toneFlag(取流请求用) */
|
||||
const QUALITY_TONE: Record<string, string> = {
|
||||
'64k': 'LQ',
|
||||
'128k': 'PQ',
|
||||
'320k': 'HQ',
|
||||
flac: 'SQ',
|
||||
flac24bit: 'ZQ24',
|
||||
wav: 'ZQ32',
|
||||
}
|
||||
|
||||
const TONE_EXT: Record<string, string> = {
|
||||
LQ: 'mp3', PQ: 'mp3', HQ: 'mp3', SQ: 'flac', ZQ: 'flac', ZQ24: 'flac',
|
||||
ZQ32: 'wav', Z3D: 'wav', I3D: 'm4a', '3D60': 'wav',
|
||||
}
|
||||
|
||||
/** 可重写的 tone 路径(上游 miguTonePaths) */
|
||||
const TONE_PATHS: Record<string, string> = {
|
||||
LQ: '全曲试听/Mp3_64_22_16',
|
||||
PQ: '标清高清/MP3_128_16_Stero',
|
||||
HQ: '标清高清/MP3_320_16_Stero',
|
||||
SQ: '歌曲下载/flac',
|
||||
ZQ: '歌曲下载/flac_24bit',
|
||||
ZQ24: '歌曲下载/flac_24bit',
|
||||
ZQ32: '歌曲下载/wav_32bit',
|
||||
}
|
||||
|
||||
const TONE_PREFERENCE = ['ZQ32', 'ZQ24', 'ZQ', 'SQ', 'HQ', 'Z3D', 'I3D', '3D60', 'PQ', 'LQ']
|
||||
|
||||
const normalizeTone = (value?: string): string => {
|
||||
const v = String(value ?? '').trim().replace(/^\./, '').toUpperCase()
|
||||
return TONE_EXT[v] ? v : ''
|
||||
}
|
||||
|
||||
/** songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c` */
|
||||
export const miguSongParts = (songInfo: any): { contentId: string, resourceType: string, formatType: string } => {
|
||||
const extra = songInfo?.extra ?? {}
|
||||
let contentId = String(extra.content_id ?? '').trim()
|
||||
let resourceType = String(extra.resource_type ?? '').trim()
|
||||
let formatType = String(extra.format_type ?? '').trim()
|
||||
if (contentId && resourceType && formatType) return { contentId, resourceType, formatType }
|
||||
const parts = String(songInfo?.songmid ?? songInfo?.id ?? '').split('|')
|
||||
if (parts.length === 3) {
|
||||
return { contentId: parts[0]!.trim(), resourceType: parts[1]!.trim(), formatType: parts[2]!.trim() }
|
||||
}
|
||||
if (!contentId) contentId = String(songInfo?.songmid ?? songInfo?.id ?? '').trim()
|
||||
return { contentId, resourceType, formatType }
|
||||
}
|
||||
|
||||
interface ListenResponse {
|
||||
code?: string
|
||||
info?: string
|
||||
data?: {
|
||||
url?: string
|
||||
formatType?: string
|
||||
dialogInfo?: { text?: string }
|
||||
songItem?: MiguSongItem
|
||||
}
|
||||
}
|
||||
|
||||
/** listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误 */
|
||||
const fetchListenInfo = async(
|
||||
contentId: string, copyrightId: string, songId: string, albumId: string, resourceType: string, toneFlag: string,
|
||||
): Promise<ListenResponse> => {
|
||||
const params: Record<string, string> = {
|
||||
netType: '01',
|
||||
resourceType: firstNonEmpty(resourceType, '2'),
|
||||
contentId: contentId.trim(),
|
||||
toneFlag: firstNonEmpty(toneFlag, 'PQ'),
|
||||
}
|
||||
if (copyrightId.trim()) params.copyrightId = copyrightId.trim()
|
||||
if (songId.trim()) params.songId = songId.trim()
|
||||
if (albumId.trim()) params.albumId = albumId.trim()
|
||||
|
||||
const cookie = getMiguCookie()
|
||||
const headers: Record<string, string> = {
|
||||
'User-Agent': AndroidUA,
|
||||
channel: AndroidChannel,
|
||||
version: AndroidVersion,
|
||||
Referer: 'https://music.migu.cn/',
|
||||
}
|
||||
if (cookie) headers.Cookie = cookie
|
||||
|
||||
const { statusCode, body } = await httpFetch(`https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url?${new URLSearchParams(params)}`, {
|
||||
headers,
|
||||
timeout: 30000,
|
||||
}).promise
|
||||
if (statusCode < 200 || statusCode >= 300) throw new Error(`migu listen endpoint returned status ${statusCode}`)
|
||||
const result = body as ListenResponse
|
||||
if (result?.code && result.code !== '000000') throw new Error(`migu api error: ${result.info} (code ${result.code})`)
|
||||
return result
|
||||
}
|
||||
|
||||
const normalizeDownloadURL = (rawURL?: string | null): string => {
|
||||
let url = String(rawURL ?? '').trim()
|
||||
if (!url) return ''
|
||||
if (url.startsWith('//')) url = `https:${url}`
|
||||
if (url.startsWith('ftp://218.200.160.122:21/')) {
|
||||
url = `https://freetyst.nf.migu.cn/${url.slice('ftp://218.200.160.122:21/'.length)}`
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
const isEncryptedDirectURL = (rawURL: string): boolean => {
|
||||
try {
|
||||
const decodedPath = decodeURIComponent(new URL(rawURL).pathname)
|
||||
return decodedPath.includes('wav_3d/') && !decodedPath.includes('wav_3d_60s/')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
interface DownloadInfo {
|
||||
url: string
|
||||
format: string
|
||||
ext: string
|
||||
encrypted: boolean
|
||||
fileKey: string
|
||||
size: number
|
||||
}
|
||||
|
||||
const directDownloadInfo = (rawURL: string, format: string, expectedSize: number): DownloadInfo | null => {
|
||||
const url = normalizeDownloadURL(rawURL)
|
||||
if (!url) return null
|
||||
let ext = ''
|
||||
try {
|
||||
const path = new URL(url).pathname
|
||||
ext = (path.split('.').pop() ?? '').toLowerCase()
|
||||
if (!ext || ext.includes('/')) ext = ''
|
||||
} catch { ext = '' }
|
||||
let tone = normalizeTone(format)
|
||||
if (!ext) ext = TONE_EXT[tone] ?? ''
|
||||
if (!ext) ext = 'mp3'
|
||||
if (!tone) {
|
||||
// 按扩展名回填档位(上游 formatFromMiguExt)
|
||||
switch (ext) {
|
||||
case 'flac': tone = 'SQ'; break
|
||||
case 'wav': tone = 'ZQ32'; break
|
||||
case 'm4a': tone = 'I3D'; break
|
||||
default: tone = 'PQ'
|
||||
}
|
||||
}
|
||||
return { url, format: tone, ext, encrypted: false, fileKey: '', size: expectedSize }
|
||||
}
|
||||
|
||||
/** z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse */
|
||||
const embeddedDownloadInfo = (resp: ListenResponse, targetTone: string, expectedSize: number): DownloadInfo | null => {
|
||||
const code = resp?.data?.songItem?.z3dCode
|
||||
if (!code) return null
|
||||
const parseSize = (v?: string) => {
|
||||
const n = parseInt(String(v ?? '').trim(), 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
switch (targetTone) {
|
||||
case 'Z3D': {
|
||||
const url = normalizeDownloadURL(firstNonEmpty(code.androidUrl, code.url))
|
||||
const fileKey = String(code.androidFileKey ?? '').trim()
|
||||
if (!url || !fileKey) return null
|
||||
return {
|
||||
url, format: 'Z3D',
|
||||
ext: firstNonEmpty(String(code.androidFileType ?? '').replace(/^\./, ''), 'wav'),
|
||||
encrypted: true, fileKey,
|
||||
size: parseSize(code.androidSize) || expectedSize,
|
||||
}
|
||||
}
|
||||
case 'I3D': {
|
||||
const url = normalizeDownloadURL(firstNonEmpty(code.iosUrl, code.url))
|
||||
const fileKey = String(code.iosFileKey ?? '').trim()
|
||||
if (!url || !fileKey) return null
|
||||
return {
|
||||
url, format: 'I3D',
|
||||
ext: firstNonEmpty(String(code.iosFileType ?? '').replace(/^\./, ''), 'm4a'),
|
||||
encrypted: true, fileKey,
|
||||
size: parseSize(code.iosSize) || expectedSize,
|
||||
}
|
||||
}
|
||||
case '3D60': {
|
||||
const url = normalizeDownloadURL(firstNonEmpty(code.url, code.h5Url))
|
||||
if (!url) return null
|
||||
return {
|
||||
url, format: '3D60',
|
||||
ext: firstNonEmpty(String(code.fileType ?? '').replace(/^\./, ''), 'wav'),
|
||||
encrypted: false, fileKey: '',
|
||||
size: parseSize(code.size) || expectedSize,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const detectTonePath = (decodedPath: string): string => {
|
||||
for (const tone of ['ZQ24', 'ZQ', 'ZQ32', 'SQ', 'HQ', 'LQ', 'PQ']) {
|
||||
const tonePath = TONE_PATHS[tone]!
|
||||
if (tonePath && decodedPath.includes(tonePath)) return tonePath
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL) */
|
||||
const rewriteToneURL = (rawURL: string, targetTone: string): string | null => {
|
||||
const tone = normalizeTone(targetTone)
|
||||
const targetPath = TONE_PATHS[tone]
|
||||
const targetExt = TONE_EXT[tone]
|
||||
if (!targetPath || !targetExt || tone === 'Z3D' || tone === 'I3D' || tone === '3D60') return null
|
||||
let parsed: URL
|
||||
try { parsed = new URL(normalizeDownloadURL(rawURL)) } catch { return null }
|
||||
const decodedPath = decodeURIComponent(parsed.pathname)
|
||||
const sourcePath = detectTonePath(decodedPath)
|
||||
if (!sourcePath) return null
|
||||
let newPath = decodedPath.replace(sourcePath, targetPath)
|
||||
if (newPath === decodedPath) return null
|
||||
const dotIdx = newPath.lastIndexOf('.')
|
||||
if (dotIdx > newPath.lastIndexOf('/')) newPath = newPath.slice(0, dotIdx)
|
||||
newPath += `.${targetExt}`
|
||||
parsed.pathname = newPath
|
||||
return parsed.toString()
|
||||
}
|
||||
|
||||
/** 候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选(上游 buildMiguDownloadCandidates) */
|
||||
const buildCandidates = (responses: ListenResponse[], expectedSize: number): DownloadInfo[] => {
|
||||
const sources: Array<{ url: string, format: string }> = []
|
||||
const seenSources = new Set<string>()
|
||||
for (const resp of responses) {
|
||||
const candidateURL = normalizeDownloadURL(resp?.data?.url)
|
||||
if (!candidateURL) continue
|
||||
const actualFormat = normalizeTone(resp?.data?.formatType) || 'PQ'
|
||||
const key = `${actualFormat}\u0000${candidateURL}`
|
||||
if (seenSources.has(key)) continue
|
||||
seenSources.add(key)
|
||||
sources.push({ url: candidateURL, format: actualFormat })
|
||||
}
|
||||
|
||||
const candidates: DownloadInfo[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (info: DownloadInfo | null) => {
|
||||
if (!info || !info.url) return
|
||||
const key = `${info.format}\u0000${info.url}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
candidates.push(info)
|
||||
}
|
||||
|
||||
for (const tone of TONE_PREFERENCE) {
|
||||
for (const resp of responses) add(embeddedDownloadInfo(resp, tone, expectedSize))
|
||||
for (const source of sources) {
|
||||
if (tone === source.format) {
|
||||
add(directDownloadInfo(source.url, source.format, expectedSize))
|
||||
continue
|
||||
}
|
||||
const rewritten = rewriteToneURL(source.url, tone)
|
||||
if (rewritten) add(directDownloadInfo(rewritten, tone, 0))
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/** 探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积 */
|
||||
const downloadInfoValid = async(info: DownloadInfo): Promise<boolean> => {
|
||||
try {
|
||||
const cookie = getMiguCookie()
|
||||
const headers: Record<string, string> = { 'User-Agent': AndroidUA, Referer: 'https://music.migu.cn/', Range: 'bytes=0-63' }
|
||||
if (cookie) headers.Cookie = cookie
|
||||
const { statusCode, headers: resHeaders, raw } = await httpFetch(info.url, { headers, timeout: 10000 }).promise
|
||||
if (statusCode < 200 || statusCode >= 300) return false
|
||||
const prefix = Buffer.isBuffer(raw) ? raw.subarray(0, 64) : Buffer.alloc(0)
|
||||
if (!prefix.length) return false
|
||||
if (info.encrypted) {
|
||||
try { decryptZ3D(prefix, info.fileKey) } catch { return false }
|
||||
} else if (!isMiguAudioMagic(prefix)) {
|
||||
return false
|
||||
}
|
||||
const contentRange = String((resHeaders as any)['content-range'] ?? '')
|
||||
const slash = contentRange.lastIndexOf('/')
|
||||
if (slash >= 0) {
|
||||
const size = parseInt(contentRange.slice(slash + 1).trim(), 10)
|
||||
if (Number.isFinite(size) && size > 0) info.size = size
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_PROBES = 10
|
||||
|
||||
export interface MiguUrlResult {
|
||||
type: string
|
||||
url: string
|
||||
/** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/migu 代理) */
|
||||
encrypted?: boolean
|
||||
fileKey?: string
|
||||
ext?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
/** 取流入口:候选链 + 逐档探测;返回实际命中的最高档位 */
|
||||
export const getMusicUrl = (songInfo: any, type: string) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
requestObj.promise = (async(): Promise<MiguUrlResult> => {
|
||||
const { contentId, resourceType, formatType } = miguSongParts(songInfo)
|
||||
if (!contentId) throw new Error('migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)')
|
||||
const rt = resourceType || '2'
|
||||
const targetTone = normalizeTone(QUALITY_TONE[type] ?? '')
|
||||
|| normalizeTone(formatType)
|
||||
|| normalizeTone(songInfo?.ext)
|
||||
|| 'PQ'
|
||||
|
||||
const copyrightId = String(songInfo?.extra?.copyright_id ?? '')
|
||||
const songId = String(songInfo?.extra?.song_id ?? '')
|
||||
const albumId = String(songInfo?.extra?.album_id ?? songInfo?.albumId ?? '')
|
||||
|
||||
const requestTones: string[] = []
|
||||
for (const tone of ['ZQ32', targetTone, 'PQ']) {
|
||||
if (!requestTones.includes(tone)) requestTones.push(tone)
|
||||
}
|
||||
|
||||
const responses: ListenResponse[] = []
|
||||
let fetchErr: Error | null = null
|
||||
for (const tone of requestTones) {
|
||||
try {
|
||||
responses.push(await fetchListenInfo(contentId, copyrightId, songId, albumId, rt, tone))
|
||||
} catch (err: any) {
|
||||
if (!fetchErr) fetchErr = err
|
||||
}
|
||||
}
|
||||
if (!responses.length) throw fetchErr ?? new Error('migu listen-url 全部失败')
|
||||
|
||||
const expectedSize = Number(songInfo?.size ?? 0) || 0
|
||||
const candidates = buildCandidates(responses, expectedSize)
|
||||
let probed = 0
|
||||
for (const candidate of candidates) {
|
||||
if (probed++ >= MAX_PROBES) break
|
||||
if (await downloadInfoValid(candidate)) {
|
||||
const quality = TONE_QUALITY[candidate.format] ?? type
|
||||
return {
|
||||
type: quality,
|
||||
url: candidate.url,
|
||||
...(candidate.encrypted ? { encrypted: true, fileKey: candidate.fileKey } : {}),
|
||||
ext: candidate.ext || miguFormatExt(candidate.format),
|
||||
size: candidate.size,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 全部候选不可用:优先回传接口文案(如「登录后免费试听」)
|
||||
const dialog = responses.map(r => String(r?.data?.dialogInfo?.text ?? '').trim()).find(Boolean)
|
||||
if (dialog) throw new Error(`migu 无可用音频:${dialog}(登录后可解锁更高档位)`)
|
||||
if (fetchErr) throw fetchErr
|
||||
throw new Error('migu 未返回可访问的音频档位')
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl, supportQualitys }
|
||||
@@ -0,0 +1,73 @@
|
||||
// 千千音乐 URL 获取(/v1/song/tracklink,签名通道)
|
||||
// 档位:3000=flac / 320 / 128 / 64(aac)。实测(2026-10-02):
|
||||
// - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL)
|
||||
// - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链
|
||||
// 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位)
|
||||
import { apiOk, firstNonEmpty, trackLink } from '../qian/util.ts'
|
||||
|
||||
// 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致)
|
||||
const QUALITY_RATES: Record<string, string[]> = {
|
||||
'64k': ['64'],
|
||||
'128k': ['128', '64'],
|
||||
'320k': ['320', '128', '64'],
|
||||
'flac': ['3000', '320', '128', '64'],
|
||||
'flac24bit': ['3000', '320', '128', '64'],
|
||||
}
|
||||
const DEFAULT_RATES = ['3000', '320', '128', '64']
|
||||
|
||||
export const supportQualitys = ['64k', '128k', '320k', 'flac']
|
||||
|
||||
/** 命中档位判定:以响应 format/rate/bits 为准(可能低于请求档位) */
|
||||
const typeOfHit = (requestRate: string, data: any): string => {
|
||||
const format = String(data?.format ?? '').toLowerCase()
|
||||
const rate = Number(data?.rate ?? 0)
|
||||
const bits = Number(data?.bits ?? 0)
|
||||
if (format === 'flac' || rate >= 1000) return bits === 24 ? 'flac24bit' : 'flac'
|
||||
if (rate >= 320 || requestRate === '320') return '320k'
|
||||
if (rate >= 128 || requestRate === '128') return '128k'
|
||||
return '64k'
|
||||
}
|
||||
|
||||
/** 逐档请求 tracklink,取首个带 path 的档位;register 用于取流对象取消当前请求 */
|
||||
export const getDownloadUrl = async(tsid: string, type?: string, register?: (req: any) => void) => {
|
||||
const id = String(tsid ?? '').trim()
|
||||
if (!id) throw new Error('千千歌曲 id(TSID)为空')
|
||||
const rates = QUALITY_RATES[String(type ?? '')] ?? DEFAULT_RATES
|
||||
|
||||
let lastErr: any = null
|
||||
for (const rate of rates) {
|
||||
try {
|
||||
const body = await trackLink(id, rate, register)
|
||||
if (!apiOk(body)) {
|
||||
lastErr = new Error(body?.errmsg || `千千取流失败(errno ${body?.errno})`)
|
||||
continue
|
||||
}
|
||||
const data = body?.data ?? {}
|
||||
const url = firstNonEmpty(data?.path, data?.trail_audio_info?.path)
|
||||
if (url) return { type: typeOfHit(rate, data), url }
|
||||
lastErr = new Error('千千该歌曲无可用直链(VIP/版权曲目需登录态,免费曲目可直接取流)')
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('千千未找到可用音频直链')
|
||||
}
|
||||
|
||||
export const getMusicUrl = (songInfo: any, type?: string) => {
|
||||
const requestObj: any = { promise: null, cancelHttp() {} }
|
||||
let current: any = null
|
||||
requestObj.cancelHttp = () => {
|
||||
current?.cancelHttp?.()
|
||||
current = null
|
||||
}
|
||||
requestObj.promise = (async() => {
|
||||
// 上游 GetDownloadURL:Song.URL 已有直链时直接复用(Parse 场景)
|
||||
if (songInfo?.url) return { type: type || '128k', url: String(songInfo.url) }
|
||||
const tsid = firstNonEmpty(songInfo?.songmid, songInfo?.TSID)
|
||||
if (!tsid) throw new Error('千千歌曲 id(TSID)为空')
|
||||
return getDownloadUrl(tsid, type, (req) => { current = req })
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getMusicUrl, getDownloadUrl, supportQualitys }
|
||||
+65
-19
@@ -3,20 +3,23 @@
|
||||
// 匿名(未登录): 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(抖音扫码 /login/qs/qr)。
|
||||
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
|
||||
// 登录请求失败自动降级匿名试听。
|
||||
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||
// promise resolve → { type, url }
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { createHash, randomBytes } from 'node:crypto'
|
||||
import musicInfo from '../qs/musicInfo.ts'
|
||||
import { getDevice } from '../qs/device.ts'
|
||||
import { getSessionId } from '../qs/passport.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 视账号权益)
|
||||
// 官方直连接口支持的音质:授权曲匿名只有 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'
|
||||
@@ -117,32 +120,75 @@ const pickBestEntry = (playInfoList: any) => {
|
||||
return entries[0]
|
||||
}
|
||||
|
||||
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
||||
const device = await getDevice()
|
||||
const sessionId = getSessionId()
|
||||
const url = buildSignedUrl(device)
|
||||
const requestBody = JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||
const headers = await signRequest(url, requestBody)
|
||||
const fetchTrackPlayer = async (url: string, headers: Record<string, string>, requestBody: string) => {
|
||||
const { statusCode, body } = (await httpFetch(url, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
...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: `sessionid=${sessionId}` } : {}),
|
||||
},
|
||||
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('官方签名模块不可用')
|
||||
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 = signQishuiRequest(url, headers)
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
// Apple Music 专辑(上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)
|
||||
// 详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
|
||||
// → data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
|
||||
// 链接:extractAppleID(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
|
||||
import {
|
||||
appleAmpGet,
|
||||
appleAlbumToPlaylist,
|
||||
appleSongFromCatalog,
|
||||
extractAppleID,
|
||||
fetchAllTrackResources,
|
||||
getAppleConfig,
|
||||
} from './util.ts'
|
||||
|
||||
export { searchAlbum } from './musicSearch.ts'
|
||||
|
||||
import { searchAlbum } from './musicSearch.ts'
|
||||
|
||||
/** 专辑详情(page/limit 在完整曲目上切片,与其它平台 getAlbumDetail 契约一致) */
|
||||
export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
|
||||
const albumId = extractAppleID(String(rawId ?? ''), 'album')
|
||||
if (!albumId) throw new Error(`invalid apple music album id/link: ${rawId}`)
|
||||
|
||||
const cfg = getAppleConfig()
|
||||
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/albums/${albumId}`, { extend: 'extendedAssetUrls' })
|
||||
const album = (body?.data ?? [])[0]
|
||||
if (!album) throw new Error(`apple album not found: ${albumId}`)
|
||||
|
||||
const tracks = await fetchAllTrackResources(album?.relationships?.tracks)
|
||||
const songs = tracks.map((track: any) => appleSongFromCatalog(track, albumId))
|
||||
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.max(1, Number(limit) || 50)
|
||||
const summary = appleAlbumToPlaylist(album)
|
||||
// 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐
|
||||
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
|
||||
|
||||
return {
|
||||
source: 'apple' as const,
|
||||
info: {
|
||||
id: albumId,
|
||||
name: summary.name,
|
||||
img: summary.cover,
|
||||
desc: summary.description,
|
||||
author: summary.creator,
|
||||
link: summary.link,
|
||||
trackCount: summary.trackCount,
|
||||
releaseDate: summary.extra?.release_date || '',
|
||||
recordLabel: summary.extra?.record_label || '',
|
||||
},
|
||||
list: songs.slice((p - 1) * l, (p - 1) * l + l),
|
||||
page: p,
|
||||
limit: l,
|
||||
total: songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetAlbumSongs:只返回曲目数组 */
|
||||
export const getAlbumSongs = async(id: string): Promise<any[]> => {
|
||||
const { list } = await getAlbumDetail(id, 1, 100000)
|
||||
return list
|
||||
}
|
||||
|
||||
/** 上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页) */
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
const detail = await getAlbumDetail(link, 1, 100000)
|
||||
return {
|
||||
source: 'apple' as const,
|
||||
id: detail.info.id,
|
||||
info: detail.info,
|
||||
list: detail.list,
|
||||
page: 1,
|
||||
limit: Math.max(detail.total, 1),
|
||||
total: detail.total,
|
||||
}
|
||||
}
|
||||
|
||||
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink }
|
||||
@@ -0,0 +1,106 @@
|
||||
// Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: apple)
|
||||
// 能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到,见 lyric.ts)/ 封面 /
|
||||
// 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/
|
||||
// 链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 sdk/apis/apple.ts)。
|
||||
// 凭据:上游 New(cookie) → getCredentials('apple'),cookie 形如
|
||||
// "media-user-token=xxx;storefront=cn;token=xxx",也支持 storefront/mediaUserToken/token 直填字段。
|
||||
// 未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。
|
||||
import appleApi from '../apis/apple.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import {
|
||||
appleArtworkURL,
|
||||
appleSongFromCatalog,
|
||||
appleWebURL,
|
||||
extractAppleID,
|
||||
fetchAppleSongResource,
|
||||
getAppleConfig,
|
||||
getAppleToken,
|
||||
} from './util.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
const songIdOf = (songInfo: any): string => (
|
||||
extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
|
||||
)
|
||||
|
||||
const apple = {
|
||||
/** 预热 developer token(失败不阻塞启动,路由调用时会重试) */
|
||||
init() {
|
||||
return getAppleToken().then(() => true).catch(() => false)
|
||||
},
|
||||
|
||||
musicSearch,
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return appleApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
|
||||
getLyric(songInfo, ...rest) {
|
||||
return lyric.getLyric(songInfo, ...rest)
|
||||
},
|
||||
|
||||
/** 封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort) */
|
||||
async getPic(songInfo) {
|
||||
if (songInfo?.img) return String(songInfo.img)
|
||||
const id = songIdOf(songInfo)
|
||||
if (!id) return null
|
||||
try {
|
||||
const res = await fetchAppleSongResource(id)
|
||||
return res ? (appleArtworkURL(res.attributes?.artwork, 600) || null) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
/** 歌曲详情(best-effort;等价上游 Parse(id)) */
|
||||
async getMusicInfo(songInfo) {
|
||||
const id = songIdOf(songInfo)
|
||||
if (!id) return null
|
||||
const res = await fetchAppleSongResource(id, 'lyrics,albums')
|
||||
return res ? appleSongFromCatalog(res) : null
|
||||
},
|
||||
|
||||
album: {
|
||||
getAlbumDetail: album.getAlbumDetail,
|
||||
getAlbumSongs: album.getAlbumSongs,
|
||||
searchAlbum: album.searchAlbum,
|
||||
parseAlbumLink: album.parseAlbumLink,
|
||||
},
|
||||
|
||||
playlist,
|
||||
|
||||
// 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/apple 复用
|
||||
songList: {
|
||||
sortList: [{ name: '热门', id: '1526756058' }],
|
||||
getTags() {
|
||||
return playlist.getCategories()
|
||||
},
|
||||
getList(sortId: string, tagId: string, page = 1, limit = 20) {
|
||||
const cate = String(tagId || sortId || '').trim()
|
||||
return playlist.getCategoryPlaylists(cate, page, limit)
|
||||
},
|
||||
},
|
||||
|
||||
parse,
|
||||
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
if (songInfo?.link) return String(songInfo.link)
|
||||
const id = songIdOf(songInfo)
|
||||
if (!id) return 'https://music.apple.com/'
|
||||
return appleWebURL('song', id, getAppleConfig().storefront)
|
||||
},
|
||||
}
|
||||
|
||||
/** 登录状态(media-user-token 存在性判断;无在线校验接口) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('apple') as any
|
||||
const cfg = getAppleConfig()
|
||||
return (cfg.mediaUserToken || cred?.cookie)
|
||||
? { logged: true, updatedAt: cred?.updatedAt }
|
||||
: { logged: false }
|
||||
}
|
||||
|
||||
export default apple
|
||||
@@ -0,0 +1,50 @@
|
||||
// Apple Music 歌词(上游 apple.go GetLyrics)
|
||||
// 通道:amp-api /v1/catalog/<sf>/songs/<id>?include=lyrics&extend=extendedAssetUrls
|
||||
// → data[0].relationships.lyrics.data[*].attributes.ttml
|
||||
// 上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。
|
||||
// 注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship
|
||||
// (即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词接口需要 media-user-token(订阅态)才能拿到;
|
||||
// 本实现保持上游行为:拿不到就返回 { lyric: '', tlyric: '' },不抛错。
|
||||
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
|
||||
import { appleAmpGetTracked, extractAppleID, getAppleConfig } from './util.ts'
|
||||
|
||||
export const getLyric = (songInfo: any, ..._rest: any[]) => {
|
||||
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
|
||||
const tracker = { current: null as any }
|
||||
requestObj.cancelHttp = () => {
|
||||
try {
|
||||
tracker.current?.cancelHttp?.()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
tracker.current = null
|
||||
}
|
||||
|
||||
const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song')
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
if (!songId) throw new Error('apple 歌曲 id 无效')
|
||||
const cfg = getAppleConfig()
|
||||
const body: any = await appleAmpGetTracked(
|
||||
`/v1/catalog/${cfg.storefront}/songs/${songId}`,
|
||||
{ include: 'lyrics', extend: 'extendedAssetUrls' },
|
||||
tracker,
|
||||
)
|
||||
const lyrics = body?.data?.[0]?.relationships?.lyrics?.data ?? []
|
||||
for (const rel of lyrics) {
|
||||
const text = String(rel?.attributes?.ttml ?? '')
|
||||
if (text) return { lyric: text, tlyric: '' }
|
||||
}
|
||||
return { lyric: '', tlyric: '' }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
/** 非取消句柄版:直接返回歌词文本(内部/测试用) */
|
||||
export const getLyricsText = async(songId: string): Promise<string> => {
|
||||
const result = await getLyric({ songmid: songId }).promise
|
||||
return result?.lyric || ''
|
||||
}
|
||||
|
||||
export default { getLyric }
|
||||
@@ -0,0 +1,74 @@
|
||||
// Apple Music 搜索(上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)
|
||||
// 通道:amp-api /v1/catalog/<storefront>/search(Bearer developer token,见 util.ts)
|
||||
// 单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total,
|
||||
// 因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。
|
||||
import {
|
||||
appleAmpGet,
|
||||
appleSongFromCatalog,
|
||||
appleAlbumToPlaylist,
|
||||
applePlaylistToPlaylist,
|
||||
getAppleConfig,
|
||||
} from './util.ts'
|
||||
|
||||
/** amp-api search 单页硬上限 */
|
||||
export const SEARCH_PAGE_MAX = 25
|
||||
|
||||
const catalogSearch = async(keyword: string, types: string, page: number, limit: number) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.min(Math.max(1, Number(limit) || 20), 100)
|
||||
if (!word) return { body: null, page: p, limit: l }
|
||||
const cfg = getAppleConfig()
|
||||
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/search`, {
|
||||
term: word,
|
||||
types,
|
||||
limit: Math.min(l, SEARCH_PAGE_MAX),
|
||||
offset: (p - 1) * l,
|
||||
})
|
||||
return { body, page: p, limit: l }
|
||||
}
|
||||
|
||||
/** 上游 Search:搜索歌曲 */
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const { body, page: p, limit: l } = await catalogSearch(keyword, 'songs', page, limit)
|
||||
const bucket = body?.results?.songs ?? {}
|
||||
const list = (bucket.data ?? []).map((item: any) => appleSongFromCatalog(item))
|
||||
const hasNext = Boolean(bucket.next)
|
||||
return {
|
||||
list,
|
||||
allPage: hasNext ? p + 1 : p,
|
||||
total: (p - 1) * l + list.length,
|
||||
limit: l,
|
||||
source: 'apple' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 SearchAlbum:搜索专辑(返回歌单摘要形状) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
|
||||
const { body, page: p, limit: l } = await catalogSearch(keyword, 'albums', page, limit)
|
||||
const items = body?.results?.albums?.data ?? []
|
||||
const list = items.map((item: any) => appleAlbumToPlaylist(item))
|
||||
return {
|
||||
list,
|
||||
total: (p - 1) * l + list.length,
|
||||
page: p,
|
||||
limit: l,
|
||||
source: 'apple' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 SearchPlaylist:搜索歌单 */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const { body, page: p, limit: l } = await catalogSearch(keyword, 'playlists', page, limit)
|
||||
const items = body?.results?.playlists?.data ?? []
|
||||
const list = items.map((item: any) => applePlaylistToPlaylist(item))
|
||||
return {
|
||||
list,
|
||||
total: (p - 1) * l + list.length,
|
||||
page: p,
|
||||
limit: l,
|
||||
source: 'apple' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search, searchAlbum, searchPlaylist }
|
||||
@@ -0,0 +1,22 @@
|
||||
// Apple Music 链接解析(上游 apple.go Parse / ParseAlbum / ParsePlaylist)
|
||||
// song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
|
||||
// album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
|
||||
// playlist: https://music.apple.com/us/playlist/<slug>/pl.xxxx 或裸 pl.* id
|
||||
// 上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。
|
||||
import { appleSongFromCatalog, extractAppleID, fetchAppleSongResource } from './util.ts'
|
||||
import { parseAlbumLink } from './album.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
|
||||
/** 上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const songId = extractAppleID(String(link ?? ''), 'song')
|
||||
if (!songId) throw new Error(`invalid apple music song link: ${link}`)
|
||||
const res = await fetchAppleSongResource(songId, 'lyrics,albums')
|
||||
if (!res) throw new Error(`apple song not found: ${songId}`)
|
||||
return appleSongFromCatalog(res)
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,165 @@
|
||||
// Apple Music 歌单(上游 apple.go 的 SearchPlaylist / ParsePlaylist / GetPlaylistSongs /
|
||||
// GetPlaylistCategories / GetCategoryPlaylists 与 applePlaylistToPlaylist 映射)
|
||||
// 详情:amp-api /v1/catalog/<sf>/playlists/<id>?limit[tracks]=300&extend=extendedAssetUrls
|
||||
// 曲目超过一页时沿 relationships.tracks.next 全量拉取(上游 fetchAllTrackResources,带 20 页上限防死循环)。
|
||||
// 分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories),
|
||||
// GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。
|
||||
// 链接:extractAppleID(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist/<slug>/pl.* 链接。
|
||||
import {
|
||||
appleAmpGet,
|
||||
applePlaylistToPlaylist,
|
||||
appleSongFromCatalog,
|
||||
extractAppleID,
|
||||
fetchAllTrackResources,
|
||||
getAppleConfig,
|
||||
} from './util.ts'
|
||||
|
||||
export { searchPlaylist } from './musicSearch.ts'
|
||||
|
||||
import { searchPlaylist } from './musicSearch.ts'
|
||||
|
||||
/** 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators) */
|
||||
export const appleCuratorCategories: Array<{ id: string, name: string }> = [
|
||||
{ id: '1526756058', name: '热门' },
|
||||
{ id: '1479949880', name: 'C-Pop' },
|
||||
{ id: '1019400042', name: '国语流行' },
|
||||
{ id: '1019398918', name: '粤语流行' },
|
||||
{ id: '1019399540', name: '国际流行' },
|
||||
{ id: '1019399551', name: 'K-Pop' },
|
||||
{ id: '1019399547', name: 'J-Pop' },
|
||||
{ id: '989061415', name: '嘻哈/说唱' },
|
||||
{ id: '1019400044', name: 'R&B' },
|
||||
{ id: '1019400046', name: '摇滚' },
|
||||
{ id: '1019397973', name: '另类音乐' },
|
||||
{ id: '976439535', name: '舞曲' },
|
||||
{ id: '976439536', name: '电子' },
|
||||
{ id: '976439541', name: '独立音乐' },
|
||||
{ id: '1019399549', name: '爵士乐' },
|
||||
{ id: '1019398924', name: '古典音乐' },
|
||||
{ id: '976439528', name: '蓝调' },
|
||||
{ id: '976439534', name: '乡村音乐' },
|
||||
{ id: '1531542847', name: '拉丁音乐' },
|
||||
{ id: '988656348', name: '非洲音乐' },
|
||||
{ id: '976439543', name: '金属乐' },
|
||||
{ id: '976439550', name: '朋克乐' },
|
||||
{ id: '1019400049', name: '不插电' },
|
||||
{ id: '1231181168', name: '影视原声' },
|
||||
{ id: '1441811365', name: 'DJ 混音精选' },
|
||||
{ id: '1532467784', name: '瞩目之星' },
|
||||
{ id: '1554938339', name: '年代之声' },
|
||||
{ id: '1564180390', name: '空间音频' },
|
||||
{ id: '989010186', name: '亲子' },
|
||||
]
|
||||
|
||||
/** 上游 GetPlaylistCategories:静态返回 Apple Music genre curators */
|
||||
export const getCategories = async() => ({
|
||||
source: 'apple' as const,
|
||||
list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'apple' as const })),
|
||||
})
|
||||
|
||||
/** 上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐) */
|
||||
export const getCategoryPlaylists = async(categoryID: string, page = 1, limit = 20) => {
|
||||
const categoryId = String(categoryID ?? '').trim()
|
||||
if (!categoryId) throw new Error('apple 分类 id 不能为空')
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.min(Math.max(1, Number(limit) || 20), 300)
|
||||
const apiMax = 25
|
||||
let offset = (p - 1) * l
|
||||
const all: any[] = []
|
||||
|
||||
while (all.length < l) {
|
||||
const batchSize = Math.min(apiMax, l - all.length)
|
||||
let body: any
|
||||
try {
|
||||
body = await appleAmpGet(`/v1/catalog/cn/apple-curators/${categoryId}/playlists`, {
|
||||
limit: batchSize,
|
||||
offset,
|
||||
l: 'zh-Hans-CN',
|
||||
})
|
||||
} catch (err) {
|
||||
if (all.length > 0) break // 上游:已有数据时返回已取部分
|
||||
throw err
|
||||
}
|
||||
const data = body?.data ?? []
|
||||
all.push(...data.map((item: any) => applePlaylistToPlaylist(item)))
|
||||
// 没有更多数据或没有下一页
|
||||
if (data.length < batchSize || !body?.next) break
|
||||
offset += data.length
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'apple' as const,
|
||||
list: all.slice(0, l),
|
||||
page: p,
|
||||
limit: l,
|
||||
total: all.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单详情(全量曲目后按 page/limit 切片) */
|
||||
export const getPlaylistDetail = async(rawId: string, page = 1, limit = 100) => {
|
||||
const playlistId = extractAppleID(String(rawId ?? ''), 'playlist')
|
||||
if (!playlistId) throw new Error(`invalid apple music playlist id/link: ${rawId}`)
|
||||
|
||||
const cfg = getAppleConfig()
|
||||
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/playlists/${playlistId}`, {
|
||||
'limit[tracks]': 300,
|
||||
extend: 'extendedAssetUrls',
|
||||
})
|
||||
const item = (body?.data ?? [])[0]
|
||||
if (!item) throw new Error(`apple playlist not found: ${playlistId}`)
|
||||
|
||||
const tracks = await fetchAllTrackResources(item?.relationships?.tracks)
|
||||
const songs = tracks.map((track: any) => appleSongFromCatalog(track))
|
||||
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.max(1, Number(limit) || 100)
|
||||
const summary = applePlaylistToPlaylist(item)
|
||||
if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length
|
||||
|
||||
return {
|
||||
source: 'apple' as const,
|
||||
info: {
|
||||
id: playlistId,
|
||||
name: summary.name,
|
||||
img: summary.cover,
|
||||
desc: summary.description,
|
||||
author: summary.creator,
|
||||
link: summary.link,
|
||||
trackCount: summary.trackCount,
|
||||
},
|
||||
list: songs.slice((p - 1) * l, (p - 1) * l + l),
|
||||
page: p,
|
||||
limit: l,
|
||||
total: songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistSongs:只返回曲目数组 */
|
||||
export const getPlaylistSongs = async(id: string): Promise<any[]> => {
|
||||
const { list } = await getPlaylistDetail(id, 1, 100000)
|
||||
return list
|
||||
}
|
||||
|
||||
/** 上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页) */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const detail = await getPlaylistDetail(link, 1, 100000)
|
||||
return {
|
||||
source: 'apple' as const,
|
||||
id: detail.info.id,
|
||||
info: detail.info,
|
||||
list: detail.list,
|
||||
page: 1,
|
||||
limit: Math.max(detail.total, 1),
|
||||
total: detail.total,
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// Apple Music(music.apple.com / amp-api.music.apple.com)公共工具
|
||||
// 上游 music-lib/apple/apple.go 的移植基础层:
|
||||
// - New(cookie) → getCredentials('apple'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx"
|
||||
// - fetchAppleToken():从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer)
|
||||
// 当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi({"typ":...})开头而非上游正则写的 eyJh,
|
||||
// 故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取见 getAppleToken/invalidateAppleToken。
|
||||
// - ampGet/ampHeaders:amp-api 请求(httpFetch 不自动跟 302,首页跳转用 fetchFollow 手动跟)
|
||||
// - 模型转换:appleSongFromCatalog / appleAlbumToPlaylist / applePlaylistToPlaylist(字段映射同上游)
|
||||
// - ID 提取:extractAppleID / extractAppleIDFromPath / isAppleRawID(apple_test.go 的断言全部逐条保留)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime } from '../common/utils.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
export const APPLE_HOMEPAGE_URL = 'https://music.apple.com'
|
||||
export const APPLE_AMP_API_URL = 'https://amp-api.music.apple.com'
|
||||
export const DEFAULT_STOREFRONT = 'us'
|
||||
export const APPLE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
|
||||
|
||||
/** 上游 New(cookie) 的等价配置(cookie 字符串解析 + 凭据直填字段) */
|
||||
export interface AppleRuntimeConfig {
|
||||
storefront: string
|
||||
/** media-user-token(订阅用户 token;歌词/完整曲目等受限资源需要) */
|
||||
mediaUserToken: string
|
||||
/** 显式提供的 developer token(上游 cookie 里的 token=),优先于网页抓取 */
|
||||
token: string
|
||||
}
|
||||
|
||||
/** 上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白 */
|
||||
export const parseCookieParts = (cookie: string): Record<string, string> => {
|
||||
const result: Record<string, string> = {}
|
||||
for (const part of String(cookie ?? '').split(';')) {
|
||||
const trimmed = part.trim()
|
||||
const idx = trimmed.indexOf('=')
|
||||
if (idx > 0) {
|
||||
const key = trimmed.slice(0, idx).trim()
|
||||
const val = trimmed.slice(idx + 1).trim()
|
||||
result[key] = val
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** 读取 apple 平台运行时配置(对应上游 New(cookie)) */
|
||||
export const getAppleConfig = (): AppleRuntimeConfig => {
|
||||
const cred = (getCredentials('apple') as any) || {}
|
||||
const parts = parseCookieParts(String(cred.cookie ?? ''))
|
||||
const storefront = String(cred.storefront || parts.storefront || DEFAULT_STOREFRONT).trim()
|
||||
return {
|
||||
storefront: storefront || DEFAULT_STOREFRONT,
|
||||
mediaUserToken: String(cred.mediaUserToken || parts['media-user-token'] || '').trim(),
|
||||
token: String(cred.token || parts.token || '').trim(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- developer token ----------------
|
||||
|
||||
let cachedToken = ''
|
||||
let cachedAt = 0
|
||||
let pendingToken: Promise<string> | null = null
|
||||
// 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取
|
||||
let bypassExplicitToken = false
|
||||
// JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle
|
||||
const TOKEN_CACHE_TTL = 12 * 60 * 60 * 1000
|
||||
|
||||
/** 解码 JWT payload(Apple token 为 ES256 JWT) */
|
||||
export const decodeAppleTokenPayload = (token: string): any => {
|
||||
try {
|
||||
const part = String(token ?? '').split('.')[1] || ''
|
||||
const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8')
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export const isAppleTokenExpired = (token: string): boolean => {
|
||||
const exp = Number(decodeAppleTokenPayload(token)?.exp || 0)
|
||||
return exp > 0 && exp * 1000 < Date.now() + 60 * 1000
|
||||
}
|
||||
|
||||
/** 从 index JS 中提取所有 JWT 形态的候选 token(含引号/等号前缀的主匹配 + 无前缀回退) */
|
||||
export const extractAppleTokens = (js: string): string[] => {
|
||||
const pattern = '(eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})'
|
||||
const found: string[] = []
|
||||
let m: RegExpExecArray | null
|
||||
const primary = new RegExp(`(?:=["'])${pattern}`, 'g')
|
||||
while ((m = primary.exec(String(js ?? '')))) found.push(m[1])
|
||||
if (!found.length) {
|
||||
const loose = new RegExp(pattern, 'g')
|
||||
while ((m = loose.exec(String(js ?? '')))) found.push(m[1])
|
||||
}
|
||||
return [...new Set(found)]
|
||||
}
|
||||
|
||||
/** 选择可用 token:优先 issuer=AMPWebPlay(amp-api 使用的那个),其次第一个未过期;全部过期视为抓取失败 */
|
||||
export const pickAppleToken = (js: string): string => {
|
||||
const tokens = extractAppleTokens(js)
|
||||
if (!tokens.length) return ''
|
||||
const alive = tokens.filter(t => !isAppleTokenExpired(t))
|
||||
if (!alive.length) return ''
|
||||
const preferred = alive.find(t => String(decodeAppleTokenPayload(t)?.iss || '') === 'AMPWebPlay')
|
||||
return preferred || alive[0] || ''
|
||||
}
|
||||
|
||||
/** 手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳) */
|
||||
export const fetchFollow = async(url: string, headers: Record<string, string> = {}, maxHops = 5): Promise<any> => {
|
||||
let current = String(url)
|
||||
for (let hop = 0; hop < maxHops; hop++) {
|
||||
const res: any = await httpFetch(current, { headers, timeout: 20000 }).promise
|
||||
const code = Number(res?.statusCode) || 0
|
||||
const location = res?.headers?.location
|
||||
if (code >= 300 && code < 400 && location) {
|
||||
current = new URL(String(location), current).href
|
||||
continue
|
||||
}
|
||||
return res
|
||||
}
|
||||
throw new Error('apple: too many redirects')
|
||||
}
|
||||
|
||||
const responseText = (res: any): string => {
|
||||
if (typeof res?.body === 'string') return res.body
|
||||
return Buffer.isBuffer(res?.raw) ? res.raw.toString('utf8') : String(res?.body ?? '')
|
||||
}
|
||||
|
||||
/** 上游 fetchAppleToken:首页 → index-legacy JS → bearer token */
|
||||
export const fetchAppleToken = async(): Promise<string> => {
|
||||
const home = await fetchFollow(APPLE_HOMEPAGE_URL, { 'User-Agent': APPLE_UA })
|
||||
if (Number(home?.statusCode) !== 200) throw new Error(`apple: fetch homepage error: HTTP ${home?.statusCode}`)
|
||||
const html = responseText(home)
|
||||
|
||||
// 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js)
|
||||
let match = /\/(assets\/index-legacy[~-][^/"']+\.js)/.exec(html)
|
||||
if (!match) match = /\/(assets\/index[~-][^/"']+\.js)/.exec(html)
|
||||
if (!match) throw new Error('apple: index.js URI not found')
|
||||
|
||||
const jsRes = await fetchFollow(`${APPLE_HOMEPAGE_URL}/${match[1]}`, { 'User-Agent': APPLE_UA })
|
||||
if (Number(jsRes?.statusCode) !== 200) throw new Error(`apple: fetch index.js error: HTTP ${jsRes?.statusCode}`)
|
||||
const token = pickAppleToken(responseText(jsRes))
|
||||
if (!token) throw new Error('apple: token not found in index.js')
|
||||
return token
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 developer token(缓存 + JWT exp 校验 + 并发去重)
|
||||
* force=true 或显式 token 失效(401/403 后)时回源重新抓取
|
||||
*/
|
||||
export const getAppleToken = async(force = false): Promise<string> => {
|
||||
const cfg = getAppleConfig()
|
||||
if (!force && !bypassExplicitToken && cfg.token) return cfg.token
|
||||
if (!force && cachedToken && Date.now() - cachedAt < TOKEN_CACHE_TTL && !isAppleTokenExpired(cachedToken)) {
|
||||
return cachedToken
|
||||
}
|
||||
if (!pendingToken) {
|
||||
pendingToken = (async() => {
|
||||
try {
|
||||
const token = await fetchAppleToken()
|
||||
cachedToken = token
|
||||
cachedAt = Date.now()
|
||||
return token
|
||||
} finally {
|
||||
pendingToken = null
|
||||
}
|
||||
})()
|
||||
}
|
||||
return pendingToken
|
||||
}
|
||||
|
||||
/** 清除缓存 token(401/403 时调用,下次请求会回源重取) */
|
||||
export const invalidateAppleToken = (): void => {
|
||||
cachedToken = ''
|
||||
cachedAt = 0
|
||||
bypassExplicitToken = true
|
||||
}
|
||||
|
||||
/** 仅测试/调试用:重置内存态 */
|
||||
export const resetAppleTokenState = (): void => {
|
||||
cachedToken = ''
|
||||
cachedAt = 0
|
||||
bypassExplicitToken = false
|
||||
pendingToken = null
|
||||
}
|
||||
|
||||
// ---------------- amp-api 请求 ----------------
|
||||
|
||||
export const appleAmpHeaders = (token: string): Record<string, string> => {
|
||||
const cfg = getAppleConfig()
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Origin: APPLE_HOMEPAGE_URL,
|
||||
Referer: `${APPLE_HOMEPAGE_URL}/`,
|
||||
'User-Agent': APPLE_UA,
|
||||
Accept: 'application/json',
|
||||
}
|
||||
// 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带
|
||||
if (cfg.mediaUserToken) headers.Cookie = `media-user-token=${cfg.mediaUserToken}`
|
||||
return headers
|
||||
}
|
||||
|
||||
/** 拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL */
|
||||
export const buildAppleAmpUrl = (uri: string, params?: Record<string, any> | URLSearchParams | null): string => {
|
||||
const raw = String(uri ?? '')
|
||||
const base = /^https?:\/\//i.test(raw)
|
||||
? raw
|
||||
: raw.startsWith('/')
|
||||
? APPLE_AMP_API_URL + raw
|
||||
: `${APPLE_AMP_API_URL}/${raw}`
|
||||
let query = ''
|
||||
if (params instanceof URLSearchParams) {
|
||||
query = params.toString()
|
||||
} else if (params && typeof params === 'object') {
|
||||
const search = new URLSearchParams()
|
||||
for (const key of Object.keys(params)) {
|
||||
const value = params[key]
|
||||
if (value == null || value === '') continue
|
||||
search.append(key, String(value))
|
||||
}
|
||||
query = search.toString()
|
||||
}
|
||||
if (!query) return base
|
||||
return base + (base.includes('?') ? '&' : '?') + query
|
||||
}
|
||||
|
||||
const ampErrorDetail = (res: any): string => {
|
||||
const first = res?.body?.errors?.[0]
|
||||
return String(first?.detail || first?.title || '')
|
||||
}
|
||||
|
||||
/**
|
||||
* amp-api GET(带 token 失效重取:401/403 时清缓存重试一次)
|
||||
* tracker 用于把内层 httpFetch 请求对象暴露给调用方做 cancelHttp
|
||||
*/
|
||||
export const appleAmpGetTracked = async(
|
||||
uri: string,
|
||||
params: Record<string, any> | URLSearchParams | null | undefined,
|
||||
tracker?: { current: any },
|
||||
): Promise<any> => {
|
||||
const url = buildAppleAmpUrl(uri, params)
|
||||
const attempt = async(): Promise<any> => {
|
||||
const token = await getAppleToken()
|
||||
const request = httpFetch(url, { headers: appleAmpHeaders(token), timeout: 20000 })
|
||||
if (tracker) tracker.current = request
|
||||
return request.promise
|
||||
}
|
||||
|
||||
let res: any = await attempt()
|
||||
if (res?.statusCode === 401 || res?.statusCode === 403) {
|
||||
invalidateAppleToken()
|
||||
res = await attempt()
|
||||
}
|
||||
if (!(res?.statusCode >= 200 && res?.statusCode < 300)) {
|
||||
const detail = ampErrorDetail(res)
|
||||
throw new Error(`apple amp-api HTTP ${res?.statusCode}${detail ? `: ${detail}` : ''}`)
|
||||
}
|
||||
return res.body
|
||||
}
|
||||
|
||||
export const appleAmpGet = async(uri: string, params?: Record<string, any> | null): Promise<any> => (
|
||||
appleAmpGetTracked(uri, params, { current: null })
|
||||
)
|
||||
|
||||
/** 拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources;带页数上限防死循环) */
|
||||
const MAX_TRACK_PAGES = 20
|
||||
export const fetchAllTrackResources = async(tracks: any): Promise<any[]> => {
|
||||
const all: any[] = [...(tracks?.data ?? [])]
|
||||
let nextUri = String(tracks?.next ?? '').trim()
|
||||
for (let page = 0; nextUri && page < MAX_TRACK_PAGES; page++) {
|
||||
const body: any = await appleAmpGetTracked(nextUri, null, undefined)
|
||||
all.push(...(body?.data ?? []))
|
||||
nextUri = String(body?.next ?? '').trim()
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ---------------- 模型转换(字段映射同上游) ----------------
|
||||
|
||||
/** 上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位 */
|
||||
export const appleArtworkURL = (artwork: any, size = 600): string => {
|
||||
const url = String(artwork?.url ?? '')
|
||||
if (!url) return ''
|
||||
return url.replace(/\{w\}/g, String(size)).replace(/\{h\}/g, String(size))
|
||||
}
|
||||
|
||||
/** 上游 appleSongFromCatalog → 契约 Song 形状 */
|
||||
export const appleSongFromCatalog = (res: any, defaultAlbumId = ''): any => {
|
||||
const attr = res?.attributes ?? {}
|
||||
const previewURL = Array.isArray(attr.previews) && attr.previews.length ? String(attr.previews[0]?.url ?? '') : ''
|
||||
const durationSec = Math.round(Number(attr.durationInMillis ?? 0) / 1000)
|
||||
return {
|
||||
songmid: String(res?.id ?? ''),
|
||||
name: attr.name || '',
|
||||
singer: attr.artistName || '',
|
||||
albumId: String(attr.albumId || defaultAlbumId || ''),
|
||||
albumName: attr.albumName || '',
|
||||
interval: formatPlayTime(durationSec),
|
||||
img: appleArtworkURL(attr.artwork, 600) || null,
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'apple' as const,
|
||||
link: attr.url || '', // music.apple.com 网页地址
|
||||
previewUrl: previewURL, // 30-90 秒试听片段直链(Apple 不提供完整曲目明文流)
|
||||
duration: durationSec,
|
||||
isrc: attr.isrc || '',
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 appleAlbumToPlaylist → 契约歌单/专辑摘要 */
|
||||
export const appleAlbumToPlaylist = (res: any): any => {
|
||||
const attr = res?.attributes ?? {}
|
||||
return {
|
||||
id: String(res?.id ?? ''),
|
||||
name: attr.name || '',
|
||||
cover: appleArtworkURL(attr.artwork, 300),
|
||||
trackCount: Number(attr.trackCount ?? 0) || 0,
|
||||
creator: attr.artistName || '',
|
||||
description: attr.editorialNotes?.short || '',
|
||||
link: attr.url || '',
|
||||
source: 'apple' as const,
|
||||
extra: {
|
||||
release_date: attr.releaseDate || '',
|
||||
record_label: attr.recordLabel || '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游) */
|
||||
export const applePlaylistToPlaylist = (res: any): any => {
|
||||
const attr = res?.attributes ?? {}
|
||||
const description = attr.description?.short
|
||||
|| attr.description?.standard
|
||||
|| attr.editorialNotes?.short
|
||||
|| attr.editorialNotes?.standard
|
||||
|| ''
|
||||
return {
|
||||
id: String(res?.id ?? ''),
|
||||
name: attr.name || '',
|
||||
cover: appleArtworkURL(attr.artwork, 300),
|
||||
trackCount: Number(attr.trackCount ?? 0) || 0,
|
||||
creator: attr.curatorName || '',
|
||||
description,
|
||||
link: attr.url || '',
|
||||
source: 'apple' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 网页地址(搜索结果 link 缺失时的兜底) */
|
||||
export const appleWebURL = (kind: 'song' | 'album' | 'playlist', id: string, storefront = DEFAULT_STOREFRONT): string => {
|
||||
const sf = String(storefront || DEFAULT_STOREFRONT)
|
||||
return `${APPLE_HOMEPAGE_URL}/${sf}/${kind}/${id}`
|
||||
}
|
||||
|
||||
// ---------------- ID 提取(上游 extractAppleID/isAppleRawID/extractAppleIDFromPath) ----------------
|
||||
|
||||
/** 上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字) */
|
||||
export const isAppleRawID = (value: string, mediaType: string): boolean => {
|
||||
if (/[/?#]/.test(value) || value.includes('://')) return false
|
||||
if (mediaType === 'playlist') {
|
||||
if (value.startsWith('pl.')) return /^[a-zA-Z0-9._-]+$/.test(value)
|
||||
return /^[a-zA-Z0-9_-]+$/.test(value)
|
||||
}
|
||||
return /^\d+$/.test(value)
|
||||
}
|
||||
|
||||
/** 上游 extractAppleIDFromPath:在 path 段中找 <type>/<name>/<id> 或 <type>/<id> */
|
||||
export const extractAppleIDFromPath = (path: string, mediaType: string): string => {
|
||||
const trimmed = String(path ?? '').replace(/^\/+|\/+$/g, '')
|
||||
if (!trimmed) return ''
|
||||
const segments = trimmed.split('/')
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i]
|
||||
if (segment !== mediaType && segment !== `${mediaType}s`) continue
|
||||
if (i + 2 < segments.length) return segments[i + 2]
|
||||
if (i + 1 < segments.length) return segments[i + 1]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底 */
|
||||
export const extractAppleID = (link: string, mediaType: string): string => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
if (isAppleRawID(raw, mediaType)) return raw
|
||||
|
||||
// 先按 URL 解析(Go url.Parse 的宽松语义:无协议头时把 '?' 前当 path)
|
||||
let path = ''
|
||||
let query = ''
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
path = parsed.pathname || ''
|
||||
query = parsed.search || ''
|
||||
} catch {
|
||||
const idx = raw.indexOf('?')
|
||||
path = idx >= 0 ? raw.slice(0, idx) : raw
|
||||
query = idx >= 0 ? raw.slice(idx) : ''
|
||||
}
|
||||
if (path) {
|
||||
if (mediaType === 'song') {
|
||||
const songID = new URLSearchParams(query).get('i')?.trim()
|
||||
if (songID) return songID
|
||||
}
|
||||
const fromPath = extractAppleIDFromPath(path, mediaType)
|
||||
if (fromPath) return fromPath
|
||||
}
|
||||
|
||||
const patterns: RegExp[] = [
|
||||
new RegExp(`${mediaType}/[^/]+/(\\d+)`),
|
||||
new RegExp(`${mediaType}s?/[^/]+/[^/]+/(\\d+)`),
|
||||
/\/(\d+)$/,
|
||||
/[?&]i=(\d+)/,
|
||||
]
|
||||
if (mediaType === 'playlist') {
|
||||
patterns.push(
|
||||
/playlists?\/[^/?#]+\/([a-zA-Z0-9._-]+)/,
|
||||
/playlists?\/([a-zA-Z0-9._-]+)(?:[?#]|$)/,
|
||||
)
|
||||
}
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(raw)
|
||||
if (match && match[1] != null) return match[1]
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// ---------------- 共享请求 ----------------
|
||||
|
||||
/** 歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog/<sf>/songs/<id>) */
|
||||
export const fetchAppleSongResource = async(songId: string, include = ''): Promise<any> => {
|
||||
const cfg = getAppleConfig()
|
||||
const params: Record<string, string> = { extend: 'extendedAssetUrls' }
|
||||
if (include) params.include = include
|
||||
const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/songs/${songId}`, params)
|
||||
const data = body?.data ?? []
|
||||
return data.length ? data[0] : null
|
||||
}
|
||||
|
||||
export default {
|
||||
parseCookieParts,
|
||||
getAppleConfig,
|
||||
fetchAppleToken,
|
||||
getAppleToken,
|
||||
invalidateAppleToken,
|
||||
appleAmpGet,
|
||||
appleAmpGetTracked,
|
||||
appleSongFromCatalog,
|
||||
appleAlbumToPlaylist,
|
||||
applePlaylistToPlaylist,
|
||||
extractAppleID,
|
||||
isAppleRawID,
|
||||
extractAppleIDFromPath,
|
||||
}
|
||||
+35
-7
@@ -1,12 +1,17 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
|
||||
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
|
||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} }
|
||||
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
|
||||
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
|
||||
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
|
||||
const DATA_DIR = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: join(dirname(fileURLToPath(import.meta.url)), '../../data')
|
||||
const DATA_FILE = join(DATA_DIR, 'auth.json')
|
||||
|
||||
export interface WyCredentials {
|
||||
cookie: string
|
||||
@@ -72,9 +77,23 @@ let loaded = false
|
||||
|
||||
const load = (): Record<string, any> => {
|
||||
if (loaded) return cache
|
||||
if (!existsSync(DATA_FILE)) {
|
||||
cache = {}
|
||||
loaded = true
|
||||
return cache
|
||||
}
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = JSON.parse(readFileSync(DATA_FILE, 'utf8'))
|
||||
} catch (err) {
|
||||
// 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
|
||||
// 否则下一次 setCredentials 就会用空表覆盖掉本可恢复的文件。
|
||||
const backup = `${DATA_FILE}.corrupt-${Date.now()}`
|
||||
try {
|
||||
renameSync(DATA_FILE, backup)
|
||||
console.error(`[auth/store] ${DATA_FILE} 解析失败,已备份为 ${backup},本次以空凭据启动: ${(err as Error).message}`)
|
||||
} catch {
|
||||
console.error(`[auth/store] ${DATA_FILE} 解析失败且备份未成功,本次以空凭据启动: ${(err as Error).message}`)
|
||||
}
|
||||
cache = {}
|
||||
}
|
||||
loaded = true
|
||||
@@ -85,7 +104,16 @@ const save = (data: Record<string, any>): void => {
|
||||
cache = data
|
||||
loaded = true
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
// 原子写:先落同目录临时文件(0600,仅属主可读),再 rename 覆盖。
|
||||
// 直接覆写会在进程中途被杀时留下截断 JSON —— 那正是「凭据全丢」的触发路径。
|
||||
const tmp = `${DATA_FILE}.${process.pid}.tmp`
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
renameSync(tmp, DATA_FILE)
|
||||
} catch (err) {
|
||||
try { unlinkSync(tmp) } catch { /* 清理失败不掩盖原始错误 */ }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export const getCredentials = (platform: string): any => load()[platform] ?? null
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili)
|
||||
// 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/
|
||||
// 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts)
|
||||
// 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。
|
||||
// 取流入口在 sdk/apis/bili.ts(getMusicUrl),songmid 约定 `<bvid>|<cid>`。
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import pic from './pic.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import biliApi from '../apis/bili.ts'
|
||||
import { parseSongId } from './util.ts'
|
||||
|
||||
const bili = {
|
||||
musicSearch,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return biliApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo, ...rest) {
|
||||
return lyric.getLyric(songInfo, ...rest)
|
||||
},
|
||||
getPic(songInfo) {
|
||||
return pic.getPic(songInfo)
|
||||
},
|
||||
playlist,
|
||||
parse,
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
if (songInfo?.link) return String(songInfo.link)
|
||||
const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid
|
||||
if (!bvid) return 'https://www.bilibili.com/'
|
||||
const page = Number(songInfo?.page) || 0
|
||||
return `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}`
|
||||
},
|
||||
}
|
||||
|
||||
// 登录态(sdk/auth/store.ts 的 bili 凭据,扫码登录写入)
|
||||
export { status } from './login.ts'
|
||||
|
||||
export default bili
|
||||
@@ -0,0 +1,163 @@
|
||||
// Bilibili 扫码登录(上游 bilibili/login.go 的 CreateQRLogin/CheckQRLogin + account.go 的 IsVipAccount)
|
||||
// 接口:
|
||||
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
|
||||
// GET https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
|
||||
// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
|
||||
// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
|
||||
// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
|
||||
// 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。
|
||||
// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
|
||||
import QRCode from 'qrcode'
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { getCredentials, setCredentials } from '../auth/store.ts'
|
||||
import { REFERER, UA } from './util.ts'
|
||||
import { resetVipCache } from '../apis/bili.ts'
|
||||
|
||||
const QR_GENERATE_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate'
|
||||
const QR_POLL_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll'
|
||||
|
||||
/** 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制 */
|
||||
const randomChinaIp = (): string => {
|
||||
const first = [36, 42, 58, 60, 101, 106, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 171, 175, 180, 182, 183, 210, 211, 218, 219, 220, 221, 222, 223][Math.floor(Math.random() * 35)]
|
||||
return `${first}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}`
|
||||
}
|
||||
|
||||
const qrHeaders = (): Record<string, string> => {
|
||||
const ip = randomChinaIp()
|
||||
return {
|
||||
'User-Agent': UA,
|
||||
Referer: REFERER,
|
||||
'X-Forwarded-For': ip,
|
||||
'X-Real-IP': ip,
|
||||
}
|
||||
}
|
||||
|
||||
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
|
||||
const raw = headers?.['set-cookie']
|
||||
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
|
||||
const jar: Record<string, string> = {}
|
||||
for (const value of values) {
|
||||
const pair = String(value).split(';')[0].trim()
|
||||
const idx = pair.indexOf('=')
|
||||
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
|
||||
}
|
||||
return jar
|
||||
}
|
||||
|
||||
/** crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备) */
|
||||
const cookieMapFromCrossDomain = (crossUrl: string): Record<string, string> => {
|
||||
const jar: Record<string, string> = {}
|
||||
try {
|
||||
const url = new URL(String(crossUrl || ''))
|
||||
for (const name of ['DedeUserID', 'DedeUserID__ckMd5', 'SESSDATA', 'bili_jct', 'sid']) {
|
||||
const value = url.searchParams.get(name)
|
||||
if (value) jar[name] = value
|
||||
}
|
||||
} catch {
|
||||
// 非 URL 时忽略
|
||||
}
|
||||
return jar
|
||||
}
|
||||
|
||||
const cookieString = (jar: Record<string, string>): string =>
|
||||
Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
|
||||
|
||||
/** 创建扫码登录会话:返回 { key, qrurl, qrimg, expiresAt }(qrimg 可直接展示) */
|
||||
export const createQRLogin = async() => {
|
||||
const { statusCode, body } = await httpFetch(QR_GENERATE_API, { headers: qrHeaders(), timeout: 15000 }).promise as any
|
||||
if (statusCode !== 200 || !body || typeof body !== 'object') {
|
||||
throw new Error(`获取B站登录二维码失败(HTTP ${statusCode})`)
|
||||
}
|
||||
if (body.code !== 0 || !String(body.data?.qrcode_key || '').trim()) {
|
||||
throw new Error(`获取B站登录二维码失败(${body.code}${body.message ? `: ${body.message}` : ''})`)
|
||||
}
|
||||
const key = String(body.data.qrcode_key)
|
||||
const url = String(body.data.url || '')
|
||||
const qrimg = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 2, width: 360 }).catch(() => '')
|
||||
return {
|
||||
key,
|
||||
qrurl: url,
|
||||
qrimg,
|
||||
// 上游 QRLoginSession.ExpiresAt:3 分钟有效期
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 180,
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取账号资料(nav;失败不阻断登录) */
|
||||
const fetchProfile = async(cookie: string) => {
|
||||
try {
|
||||
const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', {
|
||||
headers: { 'User-Agent': UA, Referer: REFERER, Cookie: cookie },
|
||||
timeout: 15000,
|
||||
}).promise as any
|
||||
const data = body?.data ?? {}
|
||||
return {
|
||||
mid: data.mid ?? null,
|
||||
nickname: data.uname ?? null,
|
||||
vipType: Number(data.vipType) || 0,
|
||||
vipStatus: Number(data.vipStatus) || 0,
|
||||
}
|
||||
} catch {
|
||||
return { mid: null, nickname: null, vipType: 0, vipStatus: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 轮询扫码状态
|
||||
* @returns {{ code: 800|801|802|803, message: string, status: string, profile? }}
|
||||
* 800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)
|
||||
*/
|
||||
export const checkQRLogin = async(key: string) => {
|
||||
const qrcodeKey = String(key || '').trim()
|
||||
if (!qrcodeKey) throw new Error('B站扫码 key 为空')
|
||||
const { statusCode, headers, body } = await httpFetch(`${QR_POLL_API}?qrcode_key=${encodeURIComponent(qrcodeKey)}`, {
|
||||
headers: { 'User-Agent': UA, Referer: REFERER },
|
||||
timeout: 15000,
|
||||
}).promise as any
|
||||
if (statusCode !== 200) {
|
||||
throw Object.assign(new Error(`轮询B站扫码状态失败(HTTP ${statusCode})`), { status: 502 })
|
||||
}
|
||||
const data = body?.data ?? {}
|
||||
const code = Number(data.code)
|
||||
|
||||
if (code === 0) {
|
||||
const jar = { ...cookieMapFromCrossDomain(data.url), ...cookieMapFromHeaders(headers) }
|
||||
const cookie = cookieString(jar)
|
||||
if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试')
|
||||
const profile = await fetchProfile(cookie)
|
||||
setCredentials('bili', {
|
||||
cookie,
|
||||
refreshToken: String(data.refresh_token || ''),
|
||||
mid: profile.mid,
|
||||
nickname: profile.nickname,
|
||||
vipType: profile.vipType,
|
||||
vipStatus: profile.vipStatus,
|
||||
})
|
||||
resetVipCache()
|
||||
return { code: 803, message: '登录成功', status: 'success', profile }
|
||||
}
|
||||
|
||||
const statusMap: Record<number, [number, string, string]> = {
|
||||
86038: [800, '二维码已过期', 'expired'],
|
||||
86090: [802, '已扫码,待确认', 'scanned'],
|
||||
86101: [801, '等待扫码', 'waiting'],
|
||||
}
|
||||
const [mappedCode, message, status] = statusMap[code] ?? [800, String(data.message || `未知状态 ${code}`), 'failed']
|
||||
return { code: mappedCode, message, status }
|
||||
}
|
||||
|
||||
/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('bili') as any
|
||||
return cred?.cookie
|
||||
? {
|
||||
logged: true,
|
||||
nickname: cred.nickname ?? null,
|
||||
mid: cred.mid ?? null,
|
||||
vipType: cred.vipType ?? 0,
|
||||
updatedAt: cred.updatedAt,
|
||||
}
|
||||
: { logged: false }
|
||||
}
|
||||
|
||||
export default { createQRLogin, checkQRLogin, status }
|
||||
@@ -0,0 +1,12 @@
|
||||
// Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串)
|
||||
// B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致:
|
||||
// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bili 有响应而不是 404。
|
||||
export const getLyric = (_songInfo?: any, ..._rest: any[]) => {
|
||||
const requestObj = {
|
||||
promise: Promise.resolve({ lyric: '', tlyric: '' }),
|
||||
cancelHttp() {},
|
||||
}
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getLyric }
|
||||
@@ -0,0 +1,57 @@
|
||||
// Bilibili 搜索(上游 bilibili/song.go 的 `Search`)
|
||||
// 接口: https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=&page=&page_size=
|
||||
// 上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),本移植保持一致,
|
||||
// 只是把逐个串行改成了 4 并发;单条 view 失败按上游语义跳过该条。
|
||||
// 实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;风控时返回 -412/-352,会抛错由路由兜底。
|
||||
import { apiGet, cleanTitle, fetchView, makeSong, mapLimit } from './util.ts'
|
||||
|
||||
const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type'
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.min(Math.max(1, Number(limit) || 20), 50)
|
||||
const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}`
|
||||
const { statusCode, body } = await apiGet(url)
|
||||
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}` : ''})`)
|
||||
}
|
||||
|
||||
const items: any[] = body.data?.result ?? []
|
||||
const songs = await mapLimit(items, 4, async(item: any) => {
|
||||
if (!item?.bvid) return null
|
||||
const view = await fetchView(item.bvid)
|
||||
const pages: any[] = view?.pages ?? []
|
||||
if (!pages.length) return null
|
||||
const first = pages[0]
|
||||
const rootTitle = cleanTitle(item.title)
|
||||
let displayTitle = first.part || ''
|
||||
if (!displayTitle) displayTitle = rootTitle
|
||||
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
|
||||
return makeSong({
|
||||
bvid: item.bvid,
|
||||
cid: first.cid,
|
||||
name: displayTitle,
|
||||
artist: item.author || view?.owner?.name || '',
|
||||
album: item.bvid,
|
||||
duration: first.duration,
|
||||
cover: item.pic || view?.pic || '',
|
||||
page: 1,
|
||||
})
|
||||
})
|
||||
|
||||
const list = songs.filter(Boolean)
|
||||
const total = Number(body.data?.numResults) || list.length
|
||||
const numPages = Number(body.data?.numPages) || 0
|
||||
return {
|
||||
list,
|
||||
allPage: numPages || p + (list.length >= l ? 1 : 0),
|
||||
total,
|
||||
limit: l,
|
||||
source: 'bili' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,72 @@
|
||||
// Bilibili 链接解析(上游 bilibili/song.go 的 `Parse`)
|
||||
// 支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av);
|
||||
// 与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parsePlaylistLink。
|
||||
// 上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。
|
||||
import { extractAid, extractBvid, extractPage, fetchPageList, fetchView, makeSong, normalizeCover } from './util.ts'
|
||||
import { getAudioUrl } from '../apis/bili.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link || '').trim()
|
||||
const bvid = extractBvid(raw)
|
||||
const aid = extractAid(raw)
|
||||
if (!bvid && !aid) throw new Error('无效的B站链接:未找到 BV/av 号')
|
||||
|
||||
const view = await fetchView(bvid || aid)
|
||||
const pages: any[] = view?.pages ?? []
|
||||
|
||||
// 上游:合集或多分P视为歌单链接(Parse 只处理单P)
|
||||
if (view?.ugc_season || pages.length > 1) {
|
||||
throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)')
|
||||
}
|
||||
if (pages.length <= 1) {
|
||||
try {
|
||||
const pageList = await fetchPageList(view.bvid || bvid)
|
||||
if (pageList.length > 1) throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)')
|
||||
} catch (err: any) {
|
||||
if (String(err?.message || '').includes('歌单解析')) throw err
|
||||
// 分P列表接口失败时按上游语义忽略
|
||||
}
|
||||
}
|
||||
if (!pages.length) throw new Error('未找到视频分P')
|
||||
|
||||
let page = extractPage(raw)
|
||||
if (page > pages.length) page = 1
|
||||
const target = pages[page - 1]
|
||||
|
||||
const rootTitle = view.title || ''
|
||||
let displayTitle = target?.part || ''
|
||||
if (pages.length === 1 && !displayTitle) displayTitle = rootTitle
|
||||
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
|
||||
|
||||
const cid = String(target?.cid ?? '')
|
||||
const song: any = makeSong({
|
||||
bvid: view.bvid || bvid,
|
||||
cid,
|
||||
name: displayTitle,
|
||||
artist: view?.owner?.name || '',
|
||||
album: view.bvid || bvid,
|
||||
duration: target?.duration,
|
||||
cover: normalizeCover(view?.pic || ''),
|
||||
page,
|
||||
})
|
||||
|
||||
// 上游 Parse 会立即填充音频地址(失败忽略)
|
||||
if (cid) {
|
||||
try {
|
||||
const audio = await getAudioUrl(song.bvid, cid)
|
||||
if (audio?.url) {
|
||||
song.url = audio.url
|
||||
song.type = audio.type
|
||||
}
|
||||
} catch {
|
||||
// 忽略:元数据仍可返回
|
||||
}
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
/** 歌单/合集链接解析(上游 bilibili/playlist.go 的 `ParsePlaylist`) */
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parsePlaylist }
|
||||
@@ -0,0 +1,17 @@
|
||||
// Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic
|
||||
import { fetchView, normalizeCover, parseSongId } from './util.ts'
|
||||
|
||||
export const getPic = async(songInfo: any): Promise<string> => {
|
||||
const direct = String(songInfo?.img || '')
|
||||
if (/^https?:/i.test(direct)) return direct
|
||||
const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid
|
||||
if (!bvid) return ''
|
||||
try {
|
||||
const view = await fetchView(bvid)
|
||||
return normalizeCover(view?.pic || '')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export default { getPic }
|
||||
@@ -0,0 +1,257 @@
|
||||
// Bilibili 歌单(上游 bilibili/playlist.go;B站的"歌单"= 合集 ugc_season / 多分P稿件)
|
||||
// SearchPlaylist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
|
||||
// GetPlaylistSongs / ParsePlaylist: id 约定 `season:<season_id>:<mid>:<bvid>` 或 `bvid:<bvid>`;
|
||||
// GetPlaylistCategories / GetCategoryPlaylists: 上游显式返回 ErrPlaylistCategoriesUnsupported,本移植保持不支持(抛错)。
|
||||
// 注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。
|
||||
import { apiGet, buildSongsFromPages, buildSongsFromSeasonSections, cleanTitle, extractAid, extractBvid, fetchPageList, fetchSeasonArchiveIndex, fetchSeasonSongs, fetchView, mapLimit, normalizeCover } from './util.ts'
|
||||
|
||||
const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type'
|
||||
|
||||
const countSeasonEpisodes = (sections: any[]): number => {
|
||||
let count = 0
|
||||
for (const sec of sections || []) count += (sec?.episodes || []).length
|
||||
return count
|
||||
}
|
||||
|
||||
const seasonKey = (seasonId: any, mid: any, bvid: string) => `season:${seasonId}:${mid}:${bvid}`
|
||||
const bvidKey = (bvid: string) => `bvid:${bvid}`
|
||||
|
||||
/** 搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力,语义同其它平台) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.min(Math.max(1, Number(limit) || 20), 50)
|
||||
const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}`
|
||||
const { statusCode, body } = await apiGet(url)
|
||||
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}` : ''})`)
|
||||
}
|
||||
|
||||
const items: any[] = body.data?.result ?? []
|
||||
const seen = new Set<string>()
|
||||
const result = await mapLimit(items, 4, async(item: any) => {
|
||||
if (!item?.bvid) return null
|
||||
const view = await fetchView(item.bvid)
|
||||
const cover = normalizeCover(item.pic || view?.pic || '')
|
||||
const season = view?.ugc_season
|
||||
if (season) {
|
||||
const id = seasonKey(season.id, view?.owner?.mid, item.bvid)
|
||||
if (seen.has(id)) return null
|
||||
seen.add(id)
|
||||
let trackCount = countSeasonEpisodes(season.sections)
|
||||
if (!trackCount && season.id && view?.owner?.mid) {
|
||||
try {
|
||||
trackCount = (await fetchSeasonSongs(view.owner.mid, season.id)).length
|
||||
} catch {
|
||||
// 上游 fetchSeasonSongs 失败同样保持 trackCount 0
|
||||
}
|
||||
}
|
||||
return {
|
||||
id,
|
||||
name: season.title || cleanTitle(item.title),
|
||||
cover: normalizeCover(season.cover) || cover,
|
||||
trackCount,
|
||||
playCount: Number(season?.stat?.view) || undefined,
|
||||
creator: view?.owner?.name || item.author || '',
|
||||
description: season.intro || '',
|
||||
link: `https://www.bilibili.com/video/${item.bvid}`,
|
||||
source: 'bili' as const,
|
||||
}
|
||||
}
|
||||
const pages: any[] = view?.pages ?? []
|
||||
if (pages.length > 1) {
|
||||
const id = bvidKey(item.bvid)
|
||||
if (seen.has(id)) return null
|
||||
seen.add(id)
|
||||
return {
|
||||
id,
|
||||
name: cleanTitle(item.title),
|
||||
cover,
|
||||
trackCount: pages.length,
|
||||
creator: view?.owner?.name || item.author || '',
|
||||
description: '',
|
||||
link: `https://www.bilibili.com/video/${item.bvid}`,
|
||||
source: 'bili' as const,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const list = result.filter(Boolean)
|
||||
return { list, page: p, limit: l, total: list.length, source: 'bili' as const }
|
||||
}
|
||||
|
||||
/** 由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据) */
|
||||
const detailFromBvid = async(bvid: string) => {
|
||||
const view = await fetchView(bvid)
|
||||
const realBvid = view?.bvid || bvid
|
||||
let pages: any[] = view?.pages ?? []
|
||||
if (pages.length <= 1) {
|
||||
const pageList = await fetchPageList(realBvid).catch(() => null)
|
||||
if (pageList?.length) pages = pageList
|
||||
}
|
||||
if (!pages.length) throw new Error('未找到视频分P')
|
||||
const songs = buildSongsFromPages(realBvid, view?.title || '', view?.owner?.name || '', view?.pic || '', pages)
|
||||
return {
|
||||
info: {
|
||||
name: view?.title || realBvid,
|
||||
img: normalizeCover(view?.pic || ''),
|
||||
desc: String(view?.desc || ''),
|
||||
author: view?.owner?.name || '',
|
||||
},
|
||||
songs,
|
||||
}
|
||||
}
|
||||
|
||||
/** 由 `season:<season_id>:<mid>:<bvid>` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支) */
|
||||
const detailFromSeason = async(seasonId: string, mid: string, bvid: string) => {
|
||||
let info = { name: '', img: '', desc: '', author: '' }
|
||||
let songs: any[] = []
|
||||
|
||||
if (bvid) {
|
||||
const view = await fetchView(bvid)
|
||||
const season = view?.ugc_season
|
||||
if (season) {
|
||||
let archiveIndex: Record<string, { title: string, cover: string, duration: number }> = {}
|
||||
let seasonTitle = season.title || ''
|
||||
let seasonCover = season.cover || ''
|
||||
// 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续
|
||||
const idx = await fetchSeasonArchiveIndex(mid, seasonId).catch(() => null)
|
||||
if (idx) {
|
||||
archiveIndex = idx.index
|
||||
seasonTitle = seasonTitle || idx.title
|
||||
seasonCover = seasonCover || idx.cover
|
||||
}
|
||||
songs = buildSongsFromSeasonSections(season.sections || [], seasonTitle, seasonCover, view?.owner?.name || '', archiveIndex)
|
||||
info = {
|
||||
name: seasonTitle || view?.title || '',
|
||||
img: normalizeCover(seasonCover || view?.pic || ''),
|
||||
desc: season.intro || '',
|
||||
author: view?.owner?.name || '',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!songs.length) {
|
||||
songs = await fetchSeasonSongs(mid, seasonId)
|
||||
if (!info.name) {
|
||||
const idx = await fetchSeasonArchiveIndex(mid, seasonId).catch(() => null)
|
||||
if (idx) info = { ...info, name: idx.title || '', img: normalizeCover(idx.cover || ''), desc: '', author: '' }
|
||||
}
|
||||
}
|
||||
if (!songs.length) throw new Error('合集为空或合集接口不可用')
|
||||
return { info, songs }
|
||||
}
|
||||
|
||||
/** 解析 id(兼容链接/裸 BV/av/bvid: 前缀) */
|
||||
const resolveId = async(id: string): Promise<{ kind: 'link' | 'season' | 'bvid', value: string, parts?: string[] }> => {
|
||||
const raw = String(id || '').trim()
|
||||
if (/^https?:/i.test(raw)) return { kind: 'link', value: raw }
|
||||
if (/^season:/i.test(raw)) return { kind: 'season', value: raw, parts: raw.split(':') }
|
||||
// 注意先去 `bvid:` 前缀再提 BV:extractBvid 的大小写不敏感匹配会把前缀 "bvid" 本身当成 BV 号
|
||||
const bvid = extractBvid(/^bvid:/i.test(raw) ? raw.replace(/^bvid:/i, '') : raw)
|
||||
if (bvid) return { kind: 'bvid', value: bvid }
|
||||
const aid = extractAid(raw)
|
||||
if (aid) {
|
||||
const view = await fetchView(aid)
|
||||
const realBvid = view?.bvid || ''
|
||||
if (!realBvid) throw new Error('未找到视频 BV 号')
|
||||
return { kind: 'bvid', value: realBvid }
|
||||
}
|
||||
throw new Error('无效的歌单 id(支持 season:<season_id>:<mid>:<bvid>、bvid:<bvid> 或 BV 号/链接)')
|
||||
}
|
||||
|
||||
const pageSlice = (detail: { info: any, songs: any[] }, page: any, limit: any) => {
|
||||
const p = Math.max(1, Number(page) || 1)
|
||||
const l = Math.min(Math.max(1, Number(limit) || 100), 10000)
|
||||
return {
|
||||
source: 'bili' as const,
|
||||
info: detail.info,
|
||||
list: detail.songs.slice((p - 1) * l, p * l),
|
||||
page: p,
|
||||
limit: l,
|
||||
total: detail.songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单详情(契约键 playlist.getPlaylistDetail) */
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 100) => {
|
||||
const target = await resolveId(id)
|
||||
if (target.kind === 'link') return parsePlaylistLink(target.value)
|
||||
if (target.kind === 'season') {
|
||||
const parts = target.parts || []
|
||||
if (parts.length < 3) throw new Error('无效的合集 id')
|
||||
return pageSlice(await detailFromSeason(parts[1], parts[2], parts[3] || ''), page, limit)
|
||||
}
|
||||
return pageSlice(await detailFromBvid(target.value), page, limit)
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistSongs 的等价导出(只返回歌曲数组) */
|
||||
export const getPlaylistSongs = async(id: string): Promise<any[]> => {
|
||||
const detail = await getPlaylistDetail(id, 1, 10000)
|
||||
return detail.list
|
||||
}
|
||||
|
||||
/** 解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量) */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link || '').trim()
|
||||
const bvid = extractBvid(/^bvid:/i.test(raw) ? raw.replace(/^bvid:/i, '') : raw)
|
||||
if (!bvid) {
|
||||
const aid = extractAid(raw)
|
||||
if (!aid) throw new Error('无效的B站歌单链接:未找到 BV/av 号')
|
||||
const view = await fetchView(aid)
|
||||
return parsePlaylistView(view?.bvid || '', view)
|
||||
}
|
||||
const view = await fetchView(bvid)
|
||||
return parsePlaylistView(bvid, view)
|
||||
}
|
||||
|
||||
const parsePlaylistView = async(bvid: string, view: any) => {
|
||||
const season = view?.ugc_season
|
||||
if (season) {
|
||||
const id = seasonKey(season.id, view?.owner?.mid, bvid)
|
||||
const detail = await detailFromSeason(String(season.id), String(view?.owner?.mid ?? ''), bvid)
|
||||
let trackCount = countSeasonEpisodes(season.sections)
|
||||
if (!trackCount) trackCount = detail.songs.length
|
||||
return {
|
||||
source: 'bili' as const,
|
||||
id,
|
||||
info: { ...detail.info, trackCount, playCount: Number(season?.stat?.view) || undefined },
|
||||
list: detail.songs,
|
||||
page: 1,
|
||||
limit: Math.max(detail.songs.length, 1),
|
||||
total: detail.songs.length,
|
||||
}
|
||||
}
|
||||
const detail = await detailFromBvid(bvid)
|
||||
return {
|
||||
source: 'bili' as const,
|
||||
id: bvidKey(bvid),
|
||||
info: { ...detail.info, trackCount: detail.songs.length },
|
||||
list: detail.songs,
|
||||
page: 1,
|
||||
limit: Math.max(detail.songs.length, 1),
|
||||
total: detail.songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported */
|
||||
export const getCategories = async(): Promise<any> => {
|
||||
throw new Error('B站不支持歌单分类(上游未实现)')
|
||||
}
|
||||
|
||||
/** 上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported */
|
||||
export const getCategoryPlaylists = async(_id: string, _page = 1, _limit = 20): Promise<any> => {
|
||||
throw new Error('B站不支持歌单分类(上游未实现)')
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
parsePlaylistLink,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// Bilibili 公共请求助手(上游 music-lib/bilibili 的 bilibili.go/song.go/playlist.go 共享逻辑)
|
||||
// 与上游的差异(2026-10-02 实测):
|
||||
// 1) 上游硬编码的默认 cookie(buvid3=2E109C72-...; SESSDATA=your_sessdata;)当前会被 B 站风控:
|
||||
// 带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。
|
||||
// 因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的
|
||||
// buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。
|
||||
// 2) httpFetch 不跟随 302,取流的手动跟跳在 sdk/apis/bili.ts 内处理。
|
||||
// 3) 上游 New(cookie) 的登录凭据来自 sdk/auth/store.ts 的 bili 记录(扫码登录写入)。
|
||||
|
||||
import { httpFetch } from '../common/request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
import { formatPlayTime } from '../common/utils.ts'
|
||||
|
||||
export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0'
|
||||
export const REFERER = 'https://www.bilibili.com/'
|
||||
|
||||
const HOME_URL = 'https://www.bilibili.com/'
|
||||
|
||||
/** 上游 UserAgent/Referer 组合(所有接口统一携带) */
|
||||
export const biliHeaders = (cookie = ''): Record<string, string> => ({
|
||||
'User-Agent': UA,
|
||||
Referer: REFERER,
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
})
|
||||
|
||||
/** 已保存的登录 Cookie(未登录返回空串) */
|
||||
export const getLoginCookie = (): string => String((getCredentials('bili') as any)?.cookie || '')
|
||||
|
||||
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
|
||||
const raw = headers?.['set-cookie']
|
||||
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
|
||||
const jar: Record<string, string> = {}
|
||||
for (const value of values) {
|
||||
const pair = String(value).split(';')[0].trim()
|
||||
const idx = pair.indexOf('=')
|
||||
if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim()
|
||||
}
|
||||
return jar
|
||||
}
|
||||
|
||||
let anonCookie = ''
|
||||
let anonCookiePromise: Promise<string> | null = null
|
||||
|
||||
/** 请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘) */
|
||||
export const getCookie = async(): Promise<string> => {
|
||||
const saved = getLoginCookie()
|
||||
if (saved) return saved
|
||||
if (anonCookie) return anonCookie
|
||||
if (!anonCookiePromise) {
|
||||
anonCookiePromise = (async() => {
|
||||
try {
|
||||
const { headers } = await httpFetch(HOME_URL, { headers: biliHeaders(), timeout: 15000 }).promise as any
|
||||
const jar = cookieMapFromHeaders(headers)
|
||||
anonCookie = Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
|
||||
} catch {
|
||||
// 拿不到就用无 Cookie 请求(实测同样可用)
|
||||
}
|
||||
return anonCookie
|
||||
})()
|
||||
}
|
||||
return anonCookiePromise
|
||||
}
|
||||
|
||||
/** 带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名) */
|
||||
export const apiGet = async(url: string, cookie?: string): Promise<{ statusCode: number, headers: any, body: any }> => {
|
||||
const ck = cookie === undefined ? await getCookie() : cookie
|
||||
return httpFetch(url, { headers: biliHeaders(ck), timeout: 15000 }).promise as any
|
||||
}
|
||||
|
||||
/** 封面协议补全(上游 normalizeCover) */
|
||||
export const normalizeCover = (cover: string): string => {
|
||||
if (!cover) return ''
|
||||
return cover.startsWith('//') ? `https:${cover}` : cover
|
||||
}
|
||||
|
||||
/** 搜索标题去高亮标签(上游 cleanTitle) */
|
||||
export const cleanTitle = (title: string): string => {
|
||||
if (!title) return ''
|
||||
return String(title).replace(/<em class="keyword">/g, '').replace(/<\/em>/g, '')
|
||||
}
|
||||
|
||||
/** songmid 约定:`<bvid>|<cid>`(与上游 ID 格式一致) */
|
||||
export const parseSongId = (songmid: any): { bvid: string, cid: string } => {
|
||||
const raw = String(songmid ?? '')
|
||||
const idx = raw.indexOf('|')
|
||||
if (idx < 0) return { bvid: raw, cid: '' }
|
||||
return { bvid: raw.slice(0, idx), cid: raw.slice(idx + 1) }
|
||||
}
|
||||
|
||||
/** 从链接/id 提取 BV 号(上游正则 `(BV\w+)` 的等价实现,兼容小写 bv) */
|
||||
export const extractBvid = (link: string): string => String(link || '').match(/BV[0-9A-Za-z]+/i)?.[0] || ''
|
||||
|
||||
/** 从链接提取分P(上游 `[?&]p=(\d+)`) */
|
||||
export const extractPage = (link: string): number => {
|
||||
const m = String(link || '').match(/[?&]p=(\d+)/)
|
||||
const p = m ? parseInt(m[1], 10) : 0
|
||||
return p > 0 ? p : 1
|
||||
}
|
||||
|
||||
/** 从链接提取 av 号(上游未支持,任务要求兼容) */
|
||||
export const extractAid = (link: string): string => {
|
||||
const m = String(link || '').match(/\bav(\d+)\b/i)
|
||||
return m ? m[1] : ''
|
||||
}
|
||||
|
||||
/** 并发映射(限制并发数,单条失败返回 null) */
|
||||
export const mapLimit = async(items: any[], limit: number, fn: (item: any, index: number) => Promise<any>): Promise<any[]> => {
|
||||
const list = Array.isArray(items) ? items : []
|
||||
const out: any[] = new Array(list.length).fill(null)
|
||||
let next = 0
|
||||
const size = Math.max(1, Math.min(Number(limit) || 1, list.length || 1))
|
||||
await Promise.all(Array.from({ length: size }, async() => {
|
||||
while (next < list.length) {
|
||||
const i = next++
|
||||
try {
|
||||
out[i] = await fn(list[i], i)
|
||||
} catch {
|
||||
out[i] = null
|
||||
}
|
||||
}
|
||||
}))
|
||||
return out
|
||||
}
|
||||
|
||||
/** 统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid) */
|
||||
export const makeSong = ({ bvid, cid, name, artist, album, duration, cover, page = 1 }: {
|
||||
bvid: string
|
||||
cid: string | number
|
||||
name?: string
|
||||
artist?: string
|
||||
album?: string
|
||||
duration?: number | string
|
||||
cover?: string
|
||||
page?: number
|
||||
}) => {
|
||||
const seconds = Number(duration) || 0
|
||||
const url = `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}`
|
||||
return {
|
||||
songmid: `${bvid}|${cid}`,
|
||||
name: name || '',
|
||||
singer: artist || '',
|
||||
albumId: bvid || '',
|
||||
albumName: album || bvid || '',
|
||||
interval: seconds ? formatPlayTime(seconds) : 0,
|
||||
img: normalizeCover(cover || '') || null,
|
||||
lrc: null,
|
||||
types: [] as any[],
|
||||
_types: {} as Record<string, any>,
|
||||
source: 'bili' as const,
|
||||
bvid,
|
||||
cid: String(cid ?? ''),
|
||||
page,
|
||||
link: url,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 视频/分P/合集接口(上游 bilibili.go 各 fetch* 的对应实现) ----
|
||||
|
||||
/** 视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点 */
|
||||
export const fetchView = async(id: string): Promise<any> => {
|
||||
const raw = String(id || '').trim()
|
||||
if (!raw) throw new Error('B站视频 id 为空')
|
||||
const query = /^BV/i.test(raw) ? `bvid=${encodeURIComponent(raw)}` : `aid=${encodeURIComponent(raw.replace(/^av/i, ''))}`
|
||||
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/web-interface/view?${query}`)
|
||||
if (statusCode !== 200 || !body || body.code !== 0 || !body.data) {
|
||||
throw new Error(`B站视频信息获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
|
||||
}
|
||||
return body.data
|
||||
}
|
||||
|
||||
/** 分P列表(x/player/pagelist),返回 data 数组 */
|
||||
export const fetchPageList = async(bvid: string): Promise<any[]> => {
|
||||
const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid)}`)
|
||||
if (statusCode !== 200 || !body || body.code !== 0) {
|
||||
throw new Error(`B站分P列表获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
|
||||
}
|
||||
return body.data ?? []
|
||||
}
|
||||
|
||||
/** 合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex;该接口现已 404,调用方需容错) */
|
||||
export const fetchSeasonArchiveIndex = async(mid: string | number, seasonId: string | number): Promise<{ index: Record<string, { title: string, cover: string, duration: number }>, title: string, cover: string }> => {
|
||||
if (!mid || !seasonId) throw new Error('合集信息无效')
|
||||
const index: Record<string, { title: string, cover: string, duration: number }> = {}
|
||||
let seasonTitle = ''
|
||||
let seasonCover = ''
|
||||
let processed = 0
|
||||
let pageNum = 1
|
||||
const pageSize = 30
|
||||
for (;;) {
|
||||
const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
|
||||
if (!body || body.code !== 0) {
|
||||
throw new Error(`B站合集信息获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`)
|
||||
}
|
||||
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
|
||||
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
|
||||
const archives = body.data?.archives ?? []
|
||||
if (!archives.length) break
|
||||
for (const arc of archives) {
|
||||
const meta = { title: arc?.title || '', cover: normalizeCover(arc?.cover || ''), duration: Number(arc?.duration) || 0 }
|
||||
if (arc?.bvid) index[`bvid:${arc.bvid}`] = meta
|
||||
if (arc?.cid) index[`cid:${arc.cid}`] = meta
|
||||
}
|
||||
processed += archives.length
|
||||
const total = Number(body.data?.page?.total) || 0
|
||||
if (!total || processed >= total) break
|
||||
pageNum += 1
|
||||
if (pageNum > 100) break
|
||||
}
|
||||
return { index, title: seasonTitle, cover: seasonCover }
|
||||
}
|
||||
|
||||
/** 由分P数组构造歌曲(上游 buildSongsFromPages) */
|
||||
export const buildSongsFromPages = (bvid: string, rootTitle: string, author: string, cover: string, pages: any[]): any[] => {
|
||||
return (pages || []).map((page: any, i: number) => {
|
||||
const part = page?.part || ''
|
||||
let displayTitle = part
|
||||
if (pages.length === 1 && !displayTitle) displayTitle = rootTitle
|
||||
else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}`
|
||||
return makeSong({
|
||||
bvid,
|
||||
cid: page?.cid,
|
||||
name: displayTitle,
|
||||
artist: author,
|
||||
album: bvid,
|
||||
duration: page?.duration,
|
||||
cover,
|
||||
page: i + 1,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections) */
|
||||
export const buildSongsFromSeasonSections = (
|
||||
sections: any[],
|
||||
seasonTitle: string,
|
||||
seasonCover: string,
|
||||
artistName: string,
|
||||
archiveIndex: Record<string, { title: string, cover: string, duration: number }> = {},
|
||||
): any[] => {
|
||||
const songs: any[] = []
|
||||
const artist = artistName || seasonTitle
|
||||
for (const sec of sections || []) {
|
||||
for (const ep of sec?.episodes || []) {
|
||||
let cover = ep?.cover || ''
|
||||
const meta = archiveIndex[`bvid:${ep?.bvid}`] ?? (ep?.cid ? archiveIndex[`cid:${ep.cid}`] : undefined)
|
||||
if (!cover) cover = ep?.arc?.pic || ''
|
||||
if (!cover) cover = seasonCover || meta?.cover || ''
|
||||
let duration = Number(ep?.duration) || 0
|
||||
if (!duration) duration = Number(ep?.page?.duration) || 0
|
||||
if (!duration) duration = Number(ep?.arc?.duration) || 0
|
||||
if (!duration) duration = meta?.duration || 0
|
||||
let name = ep?.title || ep?.arc?.title || ep?.page?.part || meta?.title || ''
|
||||
songs.push(makeSong({
|
||||
bvid: ep?.bvid || '',
|
||||
cid: ep?.cid,
|
||||
name,
|
||||
artist,
|
||||
album: ep?.bvid || '',
|
||||
duration,
|
||||
cover,
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
}
|
||||
return songs
|
||||
}
|
||||
|
||||
/** 合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错) */
|
||||
export const fetchSeasonSongs = async(mid: string | number, seasonId: string | number): Promise<any[]> => {
|
||||
if (!mid || !seasonId) throw new Error('合集信息无效')
|
||||
const all: any[] = []
|
||||
let processed = 0
|
||||
let seasonTitle = ''
|
||||
let seasonCover = ''
|
||||
let pageNum = 1
|
||||
const pageSize = 30
|
||||
for (;;) {
|
||||
const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
|
||||
if (!body || body.code !== 0) {
|
||||
throw new Error(`B站合集列表获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`)
|
||||
}
|
||||
if (!seasonTitle) seasonTitle = body.data?.season?.title || ''
|
||||
if (!seasonCover) seasonCover = body.data?.season?.cover || ''
|
||||
const archives = body.data?.archives ?? []
|
||||
if (!archives.length) break
|
||||
for (const arc of archives) {
|
||||
if (!arc?.cid) continue
|
||||
all.push(makeSong({
|
||||
bvid: arc?.bvid || '',
|
||||
cid: arc.cid,
|
||||
name: arc?.title || '',
|
||||
artist: '',
|
||||
album: seasonTitle || arc?.bvid || '',
|
||||
duration: arc?.duration,
|
||||
cover: normalizeCover(arc?.cover || seasonCover),
|
||||
page: 1,
|
||||
}))
|
||||
}
|
||||
processed += archives.length
|
||||
const total = Number(body.data?.page?.total) || 0
|
||||
if (!total || processed >= total) break
|
||||
pageNum += 1
|
||||
if (pageNum > 100) break
|
||||
}
|
||||
return all
|
||||
}
|
||||
@@ -7,6 +7,12 @@ import xm from './xm/index.ts'
|
||||
import me from './me/index.ts'
|
||||
import qt from './qt/index.ts'
|
||||
import lr from './lr/index.ts'
|
||||
import migu from './migu/index.ts'
|
||||
import qian from './qian/index.ts'
|
||||
import joox from './joox/index.ts'
|
||||
import bili from './bili/index.ts'
|
||||
import fivesing from './5sing/index.ts'
|
||||
import apple from './apple/index.ts'
|
||||
import { supportQualitys as kwQualitys } from './apis/kw.ts'
|
||||
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
||||
import { supportQualitys as txQualitys } from './apis/tx.ts'
|
||||
@@ -16,6 +22,12 @@ import { supportQualitys as xmQualitys } from './apis/xm.ts'
|
||||
import { supportQualitys as meQualitys } from './apis/me.ts'
|
||||
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
||||
import { supportQualitys as lrQualitys } from './apis/lr.ts'
|
||||
import { supportQualitys as miguQualitys } from './apis/migu.ts'
|
||||
import { supportQualitys as qianQualitys } from './apis/qian.ts'
|
||||
import { supportQualitys as jooxQualitys } from './apis/joox.ts'
|
||||
import { supportQualitys as biliQualitys } from './apis/bili.ts'
|
||||
import { supportQualitys as fivesingQualitys } from './apis/5sing.ts'
|
||||
import { supportQualitys as appleQualitys } from './apis/apple.ts'
|
||||
import type { PlatformModule, SearchResult, SongInfo } from './types.ts'
|
||||
import {
|
||||
sizeFormate,
|
||||
@@ -41,6 +53,13 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
|
||||
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
||||
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
||||
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
|
||||
{ id: 'migu', name: '咪咕音乐', module: migu },
|
||||
{ id: 'qian', name: '千千音乐', module: qian },
|
||||
{ id: 'joox', name: 'JOOX', module: joox },
|
||||
{ id: 'bili', name: '哔哩哔哩', module: bili },
|
||||
{ id: '5sing', name: '5sing', module: fivesing },
|
||||
// Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配
|
||||
{ id: 'apple', name: 'Apple Music', module: apple, noAggregate: true },
|
||||
]
|
||||
|
||||
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
|
||||
@@ -62,6 +81,12 @@ export const supportQuality: Record<string, string[]> = {
|
||||
me: meQualitys,
|
||||
qt: qtQualitys,
|
||||
lr: lrQualitys,
|
||||
migu: miguQualitys,
|
||||
qian: qianQualitys,
|
||||
joox: jooxQualitys,
|
||||
bili: biliQualitys,
|
||||
'5sing': fivesingQualitys,
|
||||
apple: appleQualitys,
|
||||
}
|
||||
|
||||
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// JOOX 专辑(上游 joox/album.go 移植 + 新版 openjoox2 通道)
|
||||
// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(section_type=1 专辑区,item.type=2 专辑)
|
||||
// 曲目:GET https://cache.api.joox.com/openjoox2/v1/album/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
|
||||
// (web 播放器通道,需 md5 签名;实测 2026-10-02 HTTP 200)
|
||||
// 兜底:www.joox.com/<region>/album/<id> SSR __NEXT_DATA__.props.pageProps.albumData(上游做法,需 X-Forwarded-For)
|
||||
// 链接解析:ParseAlbum 的正则与裸 ID 规则同上游。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
jooxHeaders,
|
||||
jooxSign,
|
||||
jooxAlbumLink,
|
||||
normalizeJooxID,
|
||||
normalizeJooxLang,
|
||||
pickJooxImage,
|
||||
jooxArtists,
|
||||
firstNonEmpty,
|
||||
firstNonZero,
|
||||
extractJooxNextData,
|
||||
fetchJooxPage,
|
||||
} from './util.ts'
|
||||
import { JOOX_OPENJOOX2 } from './musicInfo.ts'
|
||||
import { jooxSongFromTrack } from './song.ts'
|
||||
|
||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||
|
||||
/** openjoox2 专辑曲目(含专辑元信息) */
|
||||
const fetchAlbumTracks = async(albumId: string, page: number, limit: number) => {
|
||||
const country = 'hk'
|
||||
const lang = normalizeJooxLang('zh_hk')
|
||||
const index = Math.max(0, (page - 1) * limit)
|
||||
const params = { country, lang, index, num: limit }
|
||||
const url = `${JOOX_OPENJOOX2}/album/${albumId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body || body.code) return null
|
||||
|
||||
const tracks: any[] = body?.tracks?.items ?? []
|
||||
const list = tracks.map(item => jooxSongFromTrack(item, body?.name, pickJooxImage(body?.images), albumId)).filter(Boolean)
|
||||
if (!list.length) return null
|
||||
|
||||
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(body?.name, albumId)),
|
||||
img: pickJooxImage(body?.images) || null,
|
||||
desc: String(firstNonEmpty(body?.description, body?.publish_date)),
|
||||
author: jooxArtists(body?.artist_list),
|
||||
},
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分) */
|
||||
const fetchAlbumPageData = async(albumId: string) => {
|
||||
const { body: html } = await fetchJooxPage(jooxAlbumLink(albumId))
|
||||
const nextData = extractJooxNextData(html)
|
||||
const pageProps = nextData?.props?.pageProps
|
||||
const albumData = pageProps?.albumData
|
||||
if (normalizeJooxID(albumData?.id)) return albumData
|
||||
const nested = pageProps?.content?.page?.albumData
|
||||
if (normalizeJooxID(nested?.id)) return nested
|
||||
throw new Error('joox 专辑页数据不存在')
|
||||
}
|
||||
|
||||
/** 专辑详情(openjoox2 优先,SSR 页兜底) */
|
||||
export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => {
|
||||
const albumId = normalizeJooxID(rawId)
|
||||
if (!albumId) throw new Error('joox 专辑 id 无效')
|
||||
try {
|
||||
const detail = await fetchAlbumTracks(albumId, page, limit)
|
||||
if (detail) return detail
|
||||
} catch {
|
||||
// 通道不可用时走页面兜底
|
||||
}
|
||||
return getAlbumDetailFromPage(albumId, page, limit)
|
||||
}
|
||||
|
||||
/** 专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径) */
|
||||
export const getAlbumDetailFromPage = async(rawId: string, page = 1, limit = 50) => {
|
||||
const albumId = normalizeJooxID(rawId)
|
||||
if (!albumId) throw new Error('joox 专辑 id 无效')
|
||||
const albumData = await fetchAlbumPageData(albumId)
|
||||
const items: any[] = albumData?.trackList?.items ?? []
|
||||
const pageStart = Math.max(0, (page - 1) * limit)
|
||||
const pageItems = items.slice(pageStart, pageStart + limit)
|
||||
const cover = String(firstNonEmpty(pickJooxImage(albumData?.images), albumData?.imgSrc))
|
||||
const list = pageItems
|
||||
.map(item => jooxSongFromTrack(item, albumData?.title, cover, albumId))
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 专辑无歌曲')
|
||||
|
||||
const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(albumData?.title, albumId)),
|
||||
img: cover || null,
|
||||
desc: String(firstNonEmpty(albumData?.description, albumData?.publishDate)),
|
||||
author: jooxArtists(albumData?.artistList),
|
||||
},
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑曲目(上游 GetAlbumSongs:仅曲目列表) */
|
||||
export const getAlbumSongs = async(id: string, page = 1, limit = 50) => {
|
||||
const { list } = await getAlbumDetail(id, page, limit)
|
||||
return list
|
||||
}
|
||||
|
||||
/** 专辑搜索(上游 SearchAlbum) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 20) => {
|
||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body?.section_list) {
|
||||
return { list: [], total: 0, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const section of body.section_list) {
|
||||
if (Number(section?.section_type) !== 1) continue
|
||||
for (const item of section?.item_list ?? []) {
|
||||
if (Number(item?.type) !== 2) continue
|
||||
const album = item?.album ?? {}
|
||||
const id = normalizeJooxID(album.id)
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
list.push({
|
||||
id,
|
||||
name: String(album.name ?? ''),
|
||||
cover: pickJooxImage(album.images) || null,
|
||||
creator: jooxArtists(album.artist_list),
|
||||
description: String(album.publish_date ?? '').trim(),
|
||||
publishDate: String(album.publish_date ?? '').trim(),
|
||||
trackCount: 0,
|
||||
link: jooxAlbumLink(id),
|
||||
source: 'joox' as const,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
export const parseAlbum = async(link: string) => {
|
||||
const patterns = [
|
||||
/joox\.com\/.*\/album\/([^/?#]+)/,
|
||||
/h_activity_id=([^&]+)/,
|
||||
/albumid=([^&]+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const m = pattern.exec(String(link ?? ''))
|
||||
if (m) return getAlbumDetail(m[1])
|
||||
}
|
||||
const raw = String(link ?? '').trim()
|
||||
if (raw.length > 10 && !raw.includes('/')) return getAlbumDetail(raw)
|
||||
throw new Error('invalid joox album link')
|
||||
}
|
||||
|
||||
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbum }
|
||||
@@ -0,0 +1,62 @@
|
||||
// JOOX(腾讯海外音乐,东南亚)平台模块
|
||||
// 匿名可用面(2026-10-02 实测,需 X-Forwarded-For 东南亚 IP 绕过 www 地区重定向):
|
||||
// 搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+链接解析)/ 歌词 / 链接解析(歌曲)/
|
||||
// 取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。
|
||||
// 通道:
|
||||
// - 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(无签名)
|
||||
// - 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(需 md5 签名,见 util.jooxSign)
|
||||
// - 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content)
|
||||
// - 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__
|
||||
// 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/joox 绑定后从 sdk/auth/store.ts 读取,
|
||||
// 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。
|
||||
// userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。
|
||||
import jooxApi from '../apis/joox.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import { getTrackDetail } from './musicInfo.ts'
|
||||
import { pickJooxImage, normalizeJooxID, jooxSongLink } from './util.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
const joox = {
|
||||
musicSearch,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return jooxApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo, ...rest) {
|
||||
return lyric.getLyric(songInfo, ...rest)
|
||||
},
|
||||
getPic(songInfo) {
|
||||
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? '')
|
||||
return getTrackDetail(songId).then(detail => pickJooxImage(detail?.images) || null)
|
||||
},
|
||||
album,
|
||||
playlist,
|
||||
// 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口,
|
||||
// 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/joox 路由复用。
|
||||
songList: {
|
||||
sortList: [{ name: '全部', id: '' }],
|
||||
getTags() {
|
||||
return playlist.getCategories()
|
||||
},
|
||||
getList(sortId: string, tagId: string, page = 1, limit = 30) {
|
||||
return playlist.getCategoryPlaylists(tagId ?? sortId ?? '', page, limit)
|
||||
},
|
||||
},
|
||||
parse,
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
return jooxSongLink(songInfo?.songmid ?? songInfo?.id ?? '')
|
||||
},
|
||||
}
|
||||
|
||||
/** 登录状态(凭据存在性判断;无在线校验接口) */
|
||||
export const status = () => {
|
||||
const cred = getCredentials('joox') as { cookie?: string, updatedAt?: string } | null
|
||||
return cred?.cookie
|
||||
? { logged: true, updatedAt: cred.updatedAt }
|
||||
: { logged: false }
|
||||
}
|
||||
|
||||
export default joox
|
||||
@@ -0,0 +1,84 @@
|
||||
// JOOX 歌词(上游 joox/lyric.go 移植)
|
||||
// 端点:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
|
||||
// 响应形如 MusicJsonCallback({"code":0,"lyric":"<base64>"}),lyric 为 base64 UTF-8 歌词。
|
||||
// 实测(2026-10-02)该旧接口仍可用(web_get_songinfo 已 404,但 web_lyric 未下线)。
|
||||
// 兜底:openjoox2 /track 详情的 lrc_content(同为 base64),旧接口失败时使用。
|
||||
// 解码:base64 → UTF-8 → HTML 实体(& 等)→ { lyric, tlyric: '' }
|
||||
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { jooxHeaders, decodeJooxLyric, normalizeJooxID, firstNonEmpty } from './util.ts'
|
||||
import { buildTrackUrl } from './musicInfo.ts'
|
||||
|
||||
export const LEGACY_LYRIC_URL = 'https://api.joox.com/web-fcgi-bin/web_lyric'
|
||||
|
||||
/** 解析 MusicJsonCallback(...) / 裸 JSON 包裹 */
|
||||
export const parseLyricPayload = (body: any): any => {
|
||||
if (body && typeof body === 'object') return body
|
||||
let text = String(body ?? '')
|
||||
const idx = text.indexOf('MusicJsonCallback(')
|
||||
if (idx >= 0) {
|
||||
text = text.slice(idx + 'MusicJsonCallback('.length)
|
||||
text = text.replace(/\)\s*;?\s*$/, '')
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本 */
|
||||
export const parseLegacyLyric = (statusCode: number, body: any): string => {
|
||||
if (statusCode != 200) throw new Error(`joox 歌词获取失败(HTTP ${statusCode})`)
|
||||
const payload = parseLyricPayload(body)
|
||||
if (!payload) throw new Error('joox 歌词解析失败')
|
||||
const raw = String(firstNonEmpty(payload.lyric, ''))
|
||||
if (Number(payload.code ?? 0) !== 0 || !raw) throw new Error('joox 歌词不存在或为空')
|
||||
return decodeJooxLyric(raw)
|
||||
}
|
||||
|
||||
/** 上游 GetLyrics 通道(非取消句柄版,测试/内部用) */
|
||||
export const getLegacyLyric = async(songId: string): Promise<string> => {
|
||||
const url = `${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
return parseLegacyLyric(statusCode, body)
|
||||
}
|
||||
|
||||
export const getLyric = (songInfo: any, ..._rest: any[]) => {
|
||||
const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} }
|
||||
let current: any = null
|
||||
requestObj.cancelHttp = () => {
|
||||
if (current?.cancelHttp) current.cancelHttp()
|
||||
}
|
||||
|
||||
const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '')
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
if (!songId) throw new Error('joox 歌曲 id 无效')
|
||||
|
||||
// 1) 上游 web_lyric 通道
|
||||
let legacyErr: any = null
|
||||
try {
|
||||
current = httpFetch(
|
||||
`${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`,
|
||||
{ headers: jooxHeaders(), timeout: 20000 },
|
||||
)
|
||||
const { statusCode, body } = await current.promise
|
||||
return { lyric: parseLegacyLyric(statusCode, body), tlyric: '' }
|
||||
} catch (err) {
|
||||
legacyErr = err
|
||||
}
|
||||
|
||||
// 2) 兜底:openjoox2 详情 lrc_content(base64)
|
||||
current = httpFetch(buildTrackUrl(songId), { headers: jooxHeaders(), timeout: 20000 })
|
||||
const { statusCode, body } = await current.promise as any
|
||||
const raw = String(firstNonEmpty(body?.lrc_content, ''))
|
||||
if (statusCode != 200 || !raw) throw legacyErr ?? new Error('joox 歌词不存在或为空')
|
||||
return { lyric: decodeJooxLyric(raw), tlyric: '' }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getLyric, getLegacyLyric, parseLyricPayload, parseLegacyLyric }
|
||||
@@ -0,0 +1,83 @@
|
||||
// JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)
|
||||
// 端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
|
||||
// ?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
|
||||
// 签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..')
|
||||
// 参数按插入序参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。
|
||||
// 该签名算法由官网 player-d JS bundle 逆向 + 实测比对(见 tmp/joox-test.mjs 断言)。
|
||||
// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已于 2026-10 前 404,此文件为其替代。
|
||||
// fs=1 请求完整曲目(游客仍可能拿到 preview/refrain),im=mobile 标记。
|
||||
// 返回体含 play_url_list / refrain_url / lrc_content(base64) / qrc_content(base64) /
|
||||
// has_hq / has_hifi / vip_flag / node_is_preview 等。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
jooxHeaders,
|
||||
jooxSign,
|
||||
jooxLoginCookie,
|
||||
cookieValue,
|
||||
normalizeJooxID,
|
||||
normalizeJooxLang,
|
||||
} from './util.ts'
|
||||
|
||||
export const JOOX_OPENJOOX2 = 'https://cache.api.joox.com/openjoox2/v1'
|
||||
|
||||
interface TrackOptions {
|
||||
country?: string
|
||||
lang?: string
|
||||
/** true 请求完整曲目(fs=1),false 预览(fs=0) */
|
||||
fullSong?: boolean
|
||||
isMobile?: boolean
|
||||
}
|
||||
|
||||
/** 构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk) */
|
||||
export const buildTrackUrl = (rawId: unknown, options: TrackOptions = {}): string => {
|
||||
const id = String(normalizeJooxID(rawId)).replace(/\//g, '_')
|
||||
const country = options.country || 'hk'
|
||||
const lang = normalizeJooxLang(options.lang || 'zh_TW')
|
||||
const params: Record<string, any> = {
|
||||
country,
|
||||
lang,
|
||||
lyric: 1,
|
||||
fs: options.fullSong ? 1 : 0,
|
||||
im: options.isMobile ? 1 : 0,
|
||||
}
|
||||
// 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项)
|
||||
const loginCookie = jooxLoginCookie()
|
||||
const uid = loginCookie ? cookieValue(loginCookie, 'wmid') : ''
|
||||
const usk = loginCookie ? cookieValue(loginCookie, 'session_key') : ''
|
||||
if (uid) params.uid = uid
|
||||
if (usk) params.usk = usk
|
||||
// 签名时包含 id(与 web 播放器一致),发布 query 时再删除
|
||||
const secret = jooxSign({ ...params, id })
|
||||
const query = Object.entries({ ...params, secret })
|
||||
.map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`)
|
||||
.join('&')
|
||||
return `${JOOX_OPENJOOX2}/track/${id}?${query}`
|
||||
}
|
||||
|
||||
/** 歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现) */
|
||||
export const getTrackDetail = async (rawId: unknown, options: TrackOptions = {}): Promise<any> => {
|
||||
const id = normalizeJooxID(rawId)
|
||||
if (!id) throw new Error('joox 歌曲 id 无效')
|
||||
const url = buildTrackUrl(id.replace(/\//g, '_'), options)
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body || typeof body !== 'object' || body.code) {
|
||||
const msg = body?.msg || body?.message || `HTTP ${statusCode}`
|
||||
throw new Error(`joox 歌曲详情获取失败:${msg}`)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
/** 从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 & 实体 */
|
||||
export const pickTrackUrl = (detail: any): string => {
|
||||
const clean = (value: any) => String(value ?? '').replace(/&/g, '&')
|
||||
const urls: any[] = Array.isArray(detail?.play_url_list) ? detail.play_url_list : []
|
||||
for (const url of urls) {
|
||||
const text = clean(url)
|
||||
if (text.startsWith('http')) return text
|
||||
}
|
||||
const refrain = clean(detail?.refrain_url)
|
||||
return refrain.startsWith('http') ? refrain : ''
|
||||
}
|
||||
|
||||
export default { buildTrackUrl, getTrackDetail, pickTrackUrl }
|
||||
@@ -0,0 +1,42 @@
|
||||
// JOOX 搜索(上游 joox/song.go Search 移植)
|
||||
// 端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
|
||||
// 返回 section_list(0 歌曲 / 2 歌手 / 1 专辑 / 3 歌单 / 7 用户),歌曲在 item_list[].song[].song_info。
|
||||
// 该接口无分页参数(上游也未分页),page 参数仅为统一契约保留;allPage 恒为 1。
|
||||
// 实测(2026-10-02):英文/中文关键词均 HTTP 200。
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { jooxHeaders } from './util.ts'
|
||||
import { jooxSongFromSearch } from './song.ts'
|
||||
|
||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body?.section_list) {
|
||||
return { list: [], allPage: 0, total: 0, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const section of body.section_list) {
|
||||
for (const item of section?.item_list ?? []) {
|
||||
for (const entry of item?.song ?? []) {
|
||||
const song = jooxSongFromSearch(entry?.song_info)
|
||||
if (!song || seen.has(song.songmid)) continue
|
||||
seen.add(song.songmid)
|
||||
list.push(song)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list: list.slice(0, limit),
|
||||
allPage: 1,
|
||||
total: list.length,
|
||||
limit,
|
||||
source: 'joox' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,28 @@
|
||||
// JOOX 链接解析(上游 joox/song.go Parse + album.go ParseAlbum + playlist.go ParsePlaylist)
|
||||
// 歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
|
||||
// 专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.getAlbumDetail
|
||||
// 歌单:joox.com/.../playlist/<id>、playlistid|playlist_id|id= → playlist.getPlaylistDetail
|
||||
|
||||
import { normalizeJooxID } from './util.ts'
|
||||
import { getTrackDetail, pickTrackUrl } from './musicInfo.ts'
|
||||
import { jooxSongFromDetail } from './song.ts'
|
||||
import { getAlbumDetail, parseAlbum } from './album.ts'
|
||||
import { getPlaylistDetail, parsePlaylist } from './playlist.ts'
|
||||
|
||||
/** 歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const m = /joox\.com\/.*\/single\/([^/?#]+)/.exec(raw)
|
||||
let songId = ''
|
||||
if (m) {
|
||||
songId = normalizeJooxID(m[1])
|
||||
} else if (raw.length > 10 && !raw.includes('/')) {
|
||||
songId = normalizeJooxID(raw)
|
||||
} else {
|
||||
throw new Error('invalid joox link')
|
||||
}
|
||||
const detail = await getTrackDetail(songId)
|
||||
return jooxSongFromDetail(detail, pickTrackUrl(detail))
|
||||
}
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,316 @@
|
||||
// JOOX 歌单(上游 joox/playlist.go 移植:OpenJOOX 接口 + 网页数据兜底两条路)
|
||||
// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(item.type=1 editor_playlist)
|
||||
// 曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist/<id>/tracks?...&secret= ← 新版 web 播放器通道
|
||||
// (上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
|
||||
// 兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR __NEXT_DATA__
|
||||
// props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
|
||||
// 分类:www.joox.com/sg/playlist(302 → /<region> 首页)__NEXT_DATA__.props.pageProps.mlList
|
||||
// 链接解析:joox.com/{region}/playlist/<id>、playlistid/id 查询参数、裸 ID
|
||||
|
||||
import { httpFetch } from '../request.ts'
|
||||
import {
|
||||
jooxHeaders,
|
||||
jooxSign,
|
||||
jooxPlaylistLink,
|
||||
jooxPlaylistLinks,
|
||||
normalizeJooxID,
|
||||
normalizeJooxLang,
|
||||
pickJooxImage,
|
||||
firstNonEmpty,
|
||||
firstNonZero,
|
||||
decodeJooxBase64Text,
|
||||
extractJooxNextData,
|
||||
fetchJooxPage,
|
||||
} from './util.ts'
|
||||
import { JOOX_OPENJOOX2 } from './musicInfo.ts'
|
||||
import { jooxSongFromTrack } from './song.ts'
|
||||
|
||||
const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search'
|
||||
const CATEGORY_PAGE = 'https://www.joox.com/sg/playlist'
|
||||
|
||||
const mapPlaylistSummary = (item: any) => {
|
||||
const id = normalizeJooxID(item?.id)
|
||||
if (!id) return null
|
||||
return {
|
||||
id,
|
||||
name: String(item?.name ?? ''),
|
||||
cover: pickJooxImage(item?.images) || null,
|
||||
trackCount: 0,
|
||||
creator: '',
|
||||
description: '',
|
||||
link: jooxPlaylistLink(id),
|
||||
source: 'joox' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** openjoox2 歌单曲目(含歌单元信息) */
|
||||
const fetchPlaylistTracks = async(playlistId: string, page: number, limit: number) => {
|
||||
const country = 'hk'
|
||||
const lang = normalizeJooxLang('zh_hk')
|
||||
const index = Math.max(0, (page - 1) * limit)
|
||||
const params = { country, lang, index, num: limit }
|
||||
const url = `${JOOX_OPENJOOX2}/playlist/${playlistId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body || body.code) return null
|
||||
|
||||
const list = (body?.tracks?.items ?? [])
|
||||
.map(item => jooxSongFromTrack(item, String(body?.name ?? ''), pickJooxImage(body?.images)))
|
||||
.filter(Boolean)
|
||||
if (!list.length) return null
|
||||
|
||||
const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length)
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(firstNonEmpty(body?.name, playlistId)),
|
||||
img: pickJooxImage(body?.images) || null,
|
||||
desc: String(firstNonEmpty(body?.description, body?.publish_date)),
|
||||
author: '',
|
||||
},
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** 网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植) */
|
||||
const parsePlaylistPageData = (nextData: any, pageUrl: string) => {
|
||||
const pageProps = nextData?.props?.pageProps ?? {}
|
||||
let detail: any = pageProps.playlistDetailList ?? {}
|
||||
let tracks: any[] = pageProps.allPlaylistTracks?.tracks?.items ?? []
|
||||
let trackCount = firstNonZero(
|
||||
pageProps.allPlaylistTracks?.tracks?.total_count,
|
||||
pageProps.allPlaylistTracks?.tracks?.totalCount,
|
||||
pageProps.allPlaylistTracks?.tracks?.list_count,
|
||||
)
|
||||
if (!tracks.length) {
|
||||
tracks = detail?.trackList ?? []
|
||||
trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count)
|
||||
}
|
||||
if (!tracks.length) {
|
||||
detail = pageProps.content?.page?.playlistDetailList ?? {}
|
||||
tracks = pageProps.content?.page?.allPlaylistTracks?.tracks?.items ?? []
|
||||
trackCount = firstNonZero(
|
||||
pageProps.content?.page?.allPlaylistTracks?.tracks?.total_count,
|
||||
pageProps.content?.page?.allPlaylistTracks?.tracks?.totalCount,
|
||||
pageProps.content?.page?.allPlaylistTracks?.tracks?.list_count,
|
||||
trackCount,
|
||||
)
|
||||
if (!tracks.length) {
|
||||
tracks = detail?.trackList ?? []
|
||||
trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count)
|
||||
}
|
||||
}
|
||||
if (!tracks.length) return null
|
||||
|
||||
const id = normalizeJooxID(firstNonEmpty(detail?.id, detail?.listId, detail?.playlistId))
|
||||
const name = String(firstNonEmpty(detail?.name, detail?.title, detail?.playlistName, id))
|
||||
const cover = String(firstNonEmpty(detail?.imgSrc, detail?.cover, detail?.image, detail?.pic))
|
||||
const creator = firstNonEmpty(
|
||||
detail?.creator, detail?.creatorName, detail?.userName, detail?.nickName, detail?.ownerName,
|
||||
detail?.author, detail?.authorName,
|
||||
detail?.user?.name, detail?.user?.userName, detail?.user?.nickName, detail?.user?.nick,
|
||||
detail?.owner?.name, detail?.owner?.userName, detail?.owner?.nickName, detail?.owner?.nick,
|
||||
detail?.creatorInfo?.name, detail?.creatorInfo?.userName, detail?.creatorInfo?.nickName, detail?.creatorInfo?.nick,
|
||||
)
|
||||
const description = firstNonEmpty(detail?.description, detail?.intro, detail?.desc)
|
||||
return { id, name, cover, creator, description, trackCount, tracks, pageUrl }
|
||||
}
|
||||
|
||||
/** 网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径) */
|
||||
export const getPlaylistDetailFromPage = async(rawId: string) => {
|
||||
const playlistId = normalizeJooxID(rawId)
|
||||
if (!playlistId) throw new Error('joox 歌单 id 无效')
|
||||
let lastErr: any = null
|
||||
for (const pageUrl of jooxPlaylistLinks(playlistId)) {
|
||||
try {
|
||||
const { body: html } = await fetchJooxPage(pageUrl)
|
||||
const nextData = extractJooxNextData(html)
|
||||
const parsed = parsePlaylistPageData(nextData, pageUrl)
|
||||
if (!parsed) throw new Error('joox 歌单页数据不存在')
|
||||
const pageId = normalizeJooxID(parsed.id) || playlistId
|
||||
const cover = String(parsed.cover || '')
|
||||
const list = parsed.tracks
|
||||
.map(item => jooxSongFromTrack(item, parsed.name, cover))
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 歌单无歌曲')
|
||||
return {
|
||||
source: 'joox' as const,
|
||||
info: {
|
||||
name: String(parsed.name || pageId),
|
||||
img: cover || null,
|
||||
desc: String(parsed.description || ''),
|
||||
author: String(parsed.creator || ''),
|
||||
},
|
||||
list,
|
||||
page: 1,
|
||||
limit: list.length,
|
||||
total: firstNonZero(parsed.trackCount, list.length),
|
||||
}
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new Error('joox 歌单页数据不存在')
|
||||
}
|
||||
|
||||
/** 歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留) */
|
||||
export const getPlaylistDetail = async(rawId: string, page = 1, limit = 50) => {
|
||||
const playlistId = normalizeJooxID(rawId)
|
||||
if (!playlistId) throw new Error('joox 歌单 id 无效')
|
||||
try {
|
||||
const detail = await fetchPlaylistTracks(playlistId, page, limit)
|
||||
if (detail) return detail
|
||||
} catch {
|
||||
// 通道不可用时走网页兜底
|
||||
}
|
||||
return getPlaylistDetailFromPage(playlistId)
|
||||
}
|
||||
|
||||
/** 歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底) */
|
||||
export const getPlaylistSongs = async(id: string, page = 1, limit = 50) => {
|
||||
try {
|
||||
const detail = await fetchPlaylistTracks(normalizeJooxID(id), page, limit)
|
||||
if (detail) return detail.list
|
||||
} catch {
|
||||
// 走网页兜底
|
||||
}
|
||||
const { list } = await getPlaylistDetailFromPage(normalizeJooxID(id))
|
||||
return list
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}`
|
||||
const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any
|
||||
if (statusCode != 200 || !body?.section_list) {
|
||||
return { list: [], total: 0, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
const list: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const section of body.section_list) {
|
||||
for (const item of section?.item_list ?? []) {
|
||||
if (Number(item?.type) !== 1) continue
|
||||
const summary = mapPlaylistSummary(item?.editor_playlist)
|
||||
if (!summary || seen.has(summary.id)) continue
|
||||
seen.add(summary.id)
|
||||
list.push(summary)
|
||||
}
|
||||
}
|
||||
|
||||
return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
/** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */
|
||||
const fetchPlaylistCategoriesPage = async() => {
|
||||
const { body: html } = await fetchJooxPage(CATEGORY_PAGE)
|
||||
const nextData = extractJooxNextData(html)
|
||||
const mlList = nextData?.props?.pageProps?.mlList
|
||||
if (!mlList?.category?.length) throw new Error('joox 歌单分类不存在')
|
||||
return mlList
|
||||
}
|
||||
|
||||
/** 分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一) */
|
||||
const categoryPlaylistId = (value: unknown): string => {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'number') return String(Math.trunc(value))
|
||||
return normalizeJooxID(value)
|
||||
}
|
||||
|
||||
/** 分类列表(上游 GetPlaylistCategories;首个为「全部」) */
|
||||
export const getCategories = async() => {
|
||||
const data = await fetchPlaylistCategoriesPage()
|
||||
const list: any[] = [{ id: '', name: '全部', group: '全部', hot: false }]
|
||||
data.category.forEach((category: any, index: number) => {
|
||||
const name = decodeJooxBase64Text(category?.title)
|
||||
const items: any[] = category?.itemlist ?? []
|
||||
if (!name || !items.length) return
|
||||
list.push({ id: String(index), name, group: 'JOOX', hot: false, count: items.length })
|
||||
})
|
||||
if (list.length === 1) throw new Error('joox 歌单分类不存在')
|
||||
return { source: 'joox' as const, list }
|
||||
}
|
||||
|
||||
/** 分类下歌单(上游 GetCategoryPlaylists) */
|
||||
export const getCategoryPlaylists = async(categoryId = '', page = 1, limit = 20) => {
|
||||
const data = await fetchPlaylistCategoriesPage()
|
||||
const cid = String(categoryId ?? '').trim()
|
||||
let items: any[] = []
|
||||
let categoryName = '全部'
|
||||
|
||||
if (!cid) {
|
||||
const seen = new Set<string>()
|
||||
for (const category of data.category) {
|
||||
for (const item of category?.itemlist ?? []) {
|
||||
const id = categoryPlaylistId(item?.id)
|
||||
if (!id || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
items.push(item)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let index = 0; index < data.category.length; index++) {
|
||||
const category = data.category[index]
|
||||
const name = decodeJooxBase64Text(category?.title)
|
||||
if (String(index) === cid || (name && name.toLowerCase() === cid.toLowerCase())) {
|
||||
items = category?.itemlist ?? []
|
||||
categoryName = name
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!items.length) throw new Error('joox 歌单分类不存在')
|
||||
}
|
||||
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
if (start >= items.length) throw new Error('joox 分类下无歌单')
|
||||
const pageItems = items.slice(start, start + limit)
|
||||
|
||||
const list = pageItems
|
||||
.map(item => {
|
||||
const id = categoryPlaylistId(item?.id)
|
||||
const name = decodeJooxBase64Text(item?.title)
|
||||
if (!id || !name) return null
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cover: String(item?.picurl ?? '').replace('%d', '300') || null,
|
||||
trackCount: 0,
|
||||
creator: '',
|
||||
description: '',
|
||||
link: jooxPlaylistLink(id),
|
||||
categoryId: cid,
|
||||
categoryName,
|
||||
source: 'joox' as const,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
if (!list.length) throw new Error('joox 分类下无歌单')
|
||||
|
||||
return { list, total: items.length, page, limit, source: 'joox' as const }
|
||||
}
|
||||
|
||||
/** 歌单链接解析(上游 ParsePlaylist) */
|
||||
export const parsePlaylist = async(link: string) => {
|
||||
const patterns = [
|
||||
/joox\.com\/.*\/playlist\/([^/?#]+)/,
|
||||
/(?:playlistid|playlist_id|id)=([^&]+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const m = pattern.exec(String(link ?? ''))
|
||||
if (m) return getPlaylistDetail(m[1])
|
||||
}
|
||||
const raw = String(link ?? '').trim()
|
||||
if (raw.length > 8 && !raw.includes('/')) return getPlaylistDetail(raw)
|
||||
throw new Error('invalid joox playlist link')
|
||||
}
|
||||
|
||||
export default {
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
searchPlaylist,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
parsePlaylist,
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// JOOX 歌曲形状映射(统一 Song 形状,见 tmp/port-spec.md)
|
||||
// 兼容来源:openjoox2 track/album/playlist 条目、openjoox v3 search 的 song_info、
|
||||
// 以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,上游 Go 也两套都兜底)。
|
||||
|
||||
import { formatPlayTime } from '../common/utils.ts'
|
||||
import {
|
||||
normalizeJooxID,
|
||||
pickJooxImage,
|
||||
jooxArtists,
|
||||
firstNonEmpty,
|
||||
firstNonZero,
|
||||
} from './util.ts'
|
||||
|
||||
/** 详情/列表 track 条目 → 统一 Song 形状 */
|
||||
export const jooxSongFromTrack = (
|
||||
item: any,
|
||||
fallbackAlbum = '',
|
||||
fallbackCover = '',
|
||||
fallbackAlbumId = '',
|
||||
): any | null => {
|
||||
const songmid = normalizeJooxID(item?.id)
|
||||
if (!songmid) return null
|
||||
|
||||
const artists = Array.isArray(item?.artist_list) && item.artist_list.length
|
||||
? item.artist_list
|
||||
: item?.artistList
|
||||
const albumId = normalizeJooxID(firstNonEmpty(item?.album_id, item?.albumId, fallbackAlbumId))
|
||||
|
||||
return {
|
||||
songmid,
|
||||
name: String(firstNonEmpty(item?.name, item?.title, 'Unknown')),
|
||||
singer: String(firstNonEmpty(jooxArtists(artists), 'Unknown')),
|
||||
albumId: String(albumId || ''),
|
||||
albumName: String(firstNonEmpty(item?.album_name, item?.albumName, fallbackAlbum)),
|
||||
interval: formatPlayTime(firstNonZero(item?.play_duration, item?.playDuration, item?.duration)),
|
||||
img: String(firstNonEmpty(
|
||||
pickJooxImage(item?.images),
|
||||
item?.imgSrc,
|
||||
item?.cover,
|
||||
item?.image,
|
||||
item?.pic,
|
||||
fallbackCover,
|
||||
)) || null,
|
||||
lrc: null,
|
||||
vip: Number(item?.vip_flag ?? 0) === 1,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'joox' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状 */
|
||||
export const jooxSongFromSearch = (info: any): any | null => jooxSongFromTrack(info)
|
||||
|
||||
/** 详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状 */
|
||||
export const jooxSongFromDetail = (detail: any, playUrl = ''): any => {
|
||||
const song = jooxSongFromTrack(detail) ?? {
|
||||
songmid: normalizeJooxID(detail?.id),
|
||||
name: String(detail?.name ?? ''),
|
||||
singer: String(jooxArtists(detail?.artist_list)),
|
||||
albumId: String(normalizeJooxID(detail?.album_id)),
|
||||
albumName: String(detail?.album_name ?? ''),
|
||||
interval: formatPlayTime(firstNonZero(detail?.play_duration)),
|
||||
img: String(pickJooxImage(detail?.images)) || null,
|
||||
lrc: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
source: 'joox' as const,
|
||||
}
|
||||
if (playUrl) song.url = playUrl
|
||||
song.vip = Number(detail?.vip_flag ?? 0) === 1
|
||||
return song
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取
|
||||
// 背景(2026-10-02 实测):
|
||||
// - JOOX 是东南亚服务,www.joox.com 会按来源 IP 重定向到 /intl;上游 Go 通过伪造
|
||||
// X-Forwarded-For 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。
|
||||
// - 新版 web 播放器走 cache.api.joox.com/openjoox2/v1/*,必须带 secret 签名:
|
||||
// secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent),
|
||||
// 密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。
|
||||
// - 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2),
|
||||
// 但 web_lyric 仍可用(歌词仍走上游接口)。
|
||||
// - 上游 New(cookie):cookie 用于游客态/登录态请求;登录 cookie 里的 wmid/session_key
|
||||
// 在签名参数里作为 uid/usk 参与(可拿到完整曲目/更高档位)。
|
||||
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
export const JOOX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36'
|
||||
/** 上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie) */
|
||||
export const JOOX_DEFAULT_COOKIE = 'wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;'
|
||||
/** 上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区重定向) */
|
||||
export const JOOX_XFF = '36.73.34.109'
|
||||
/** openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey) */
|
||||
export const JOOX_SECRET_KEY = 'Jo0x@t3Nc3nT'
|
||||
|
||||
export const md5Hex = (str: string): string => crypto.createHash('md5').update(str, 'utf8').digest('hex')
|
||||
|
||||
/**
|
||||
* openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接)
|
||||
* 注意键序即对象插入序(服务端按同序校验),不要重排参数。
|
||||
*/
|
||||
export const jooxSign = (params: Record<string, any>): string =>
|
||||
md5Hex(JOOX_SECRET_KEY + Object.keys(params).map(key => `${key}=${encodeURIComponent(String(params[key]))}`).join('&'))
|
||||
|
||||
/** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */
|
||||
export const jooxLoginCookie = (): string => {
|
||||
const cred = getCredentials('joox') as { cookie?: string } | null
|
||||
return cred?.cookie || ''
|
||||
}
|
||||
|
||||
/** 请求用 cookie(登录态优先;未登录回退上游游客 cookie) */
|
||||
export const jooxCookie = (): string => jooxLoginCookie() || JOOX_DEFAULT_COOKIE
|
||||
|
||||
/** 从 cookie 串里取一个字段 */
|
||||
export const cookieValue = (cookie: string, name: string): string => {
|
||||
const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(String(cookie ?? ''))
|
||||
return m ? m[1].trim() : ''
|
||||
}
|
||||
|
||||
/** 平台公共请求头(含地区伪装与 Cookie) */
|
||||
export const jooxHeaders = (extra: Record<string, any> = {}): Record<string, any> => ({
|
||||
'User-Agent': JOOX_UA,
|
||||
Cookie: jooxCookie(),
|
||||
'X-Forwarded-For': JOOX_XFF,
|
||||
Referer: 'https://www.joox.com/',
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
...extra,
|
||||
})
|
||||
|
||||
/** 语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN) */
|
||||
export const normalizeJooxLang = (lang?: string): string => {
|
||||
if (lang === 'zh_hk' || lang === 'zh_mo') return 'zh_TW'
|
||||
if (lang === 'zh_cn') return 'zh_CN'
|
||||
return lang || 'zh_TW'
|
||||
}
|
||||
|
||||
/** ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +) */
|
||||
export const normalizeJooxID = (raw: unknown): string => {
|
||||
let id = String(raw ?? '').trim()
|
||||
if (!id) return ''
|
||||
if (id.includes('%')) {
|
||||
try {
|
||||
id = decodeURIComponent(id)
|
||||
} catch {
|
||||
// 上游 PathUnescape/QueryUnescape 失败时保留原值
|
||||
}
|
||||
id = id.replace(/ /g, '+')
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export const jooxSongLink = (id: unknown): string => `https://www.joox.com/hk/single/${normalizeJooxID(id)}`
|
||||
export const jooxAlbumLink = (id: unknown): string => `https://www.joox.com/hk/album/${normalizeJooxID(id)}`
|
||||
export const jooxRegionalPlaylistLink = (region: string, id: unknown): string =>
|
||||
`https://www.joox.com/${region}/playlist/${normalizeJooxID(id)}`
|
||||
export const jooxPlaylistLink = (id: unknown): string => jooxRegionalPlaylistLink('id', id)
|
||||
/** 歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用) */
|
||||
export const jooxPlaylistLinks = (id: unknown): string[] =>
|
||||
['id', 'sg', 'hk'].map(region => jooxRegionalPlaylistLink(region, id))
|
||||
|
||||
/** 封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空) */
|
||||
export const pickJooxImage = (images: any[]): string => {
|
||||
const list = Array.isArray(images) ? images : []
|
||||
for (const width of [300, 1000, 100]) {
|
||||
for (const image of list) {
|
||||
const url = String(image?.url ?? '').trim()
|
||||
if (Number(image?.width) === width && url) return url
|
||||
}
|
||||
}
|
||||
for (const image of list) {
|
||||
const url = String(image?.url ?? '').trim()
|
||||
if (url) return url
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 歌手拼接(上游 joinJooxArtists,分隔符 ' / ') */
|
||||
export const jooxArtists = (list: any[]): string =>
|
||||
(Array.isArray(list) ? list : []).map(a => String(a?.name ?? '').trim()).filter(Boolean).join(' / ')
|
||||
|
||||
export const firstNonEmpty = (...values: any[]): any => {
|
||||
for (const value of values) {
|
||||
if (value != null && String(value).trim() !== '') return value
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const firstNonZero = (...values: any[]): number => {
|
||||
for (const value of values) {
|
||||
const n = Number(value)
|
||||
if (Number.isFinite(n) && n > 0) return n
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充) */
|
||||
export const decodeJooxBase64Text = (value: unknown): string => {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
const normalized = raw.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
|
||||
try {
|
||||
const text = Buffer.from(padded, 'base64').toString('utf8')
|
||||
// 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义)
|
||||
return text.includes('\uFFFD') ? raw : text.trim()
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
/** HTML 实体解码(歌词文本兜底) */
|
||||
export const decodeHtmlEntities = (text: unknown): string =>
|
||||
String(text ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/�*39;/g, "'")
|
||||
.replace(/ /g, ' ')
|
||||
|
||||
/** 歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底) */
|
||||
export const decodeJooxLyric = (value: unknown): string => decodeHtmlEntities(decodeJooxBase64Text(value))
|
||||
|
||||
/** 从 SSR 页面提取 __NEXT_DATA__ JSON */
|
||||
export const extractJooxNextData = (html: unknown): any => {
|
||||
const m = /<script[^>]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(String(html ?? ''))
|
||||
if (!m) return null
|
||||
try {
|
||||
return JSON.parse(m[1])
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取页面并手动跟随 3xx(request.ts 不自动跟跳;Go 客户端会自动跟)
|
||||
* 返回最终页 HTML(非字符串 body 视为空串)
|
||||
*/
|
||||
export const fetchJooxPage = async (url: string, maxRedirect = 3): Promise<{ statusCode: number, body: string, finalUrl: string }> => {
|
||||
let current = url
|
||||
for (let i = 0; i <= maxRedirect; i++) {
|
||||
const res: any = await httpFetch(current, { headers: jooxHeaders(), timeout: 20000 }).promise
|
||||
const location = res?.headers?.location
|
||||
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
|
||||
current = new URL(String(location), current).href
|
||||
continue
|
||||
}
|
||||
return { statusCode: res.statusCode, body: typeof res.body === 'string' ? res.body : '', finalUrl: current }
|
||||
}
|
||||
throw new Error('joox 页面重定向次数过多')
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getMusicInfosByList } from './musicInfo.ts'
|
||||
import { createHttpFetch } from './util.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240')
|
||||
|
||||
export default {
|
||||
/**
|
||||
@@ -60,4 +63,64 @@ export default {
|
||||
},
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 专辑搜索(上游 kugou SearchAlbum:mobilecdn v3 search/album)
|
||||
* 返回摘要统一形状(对齐契约:id/name/cover/trackCount/creator/description/link/source)
|
||||
*/
|
||||
async searchAlbum(keyword, page = 1, limit = 10) {
|
||||
const word = String(keyword ?? '').trim()
|
||||
if (!word) return { source: 'kg', list: [], page, limit, total: 0 }
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 10)
|
||||
const apiURL = `http://mobilecdn.kugou.com/api/v3/search/album?keyword=${encodeURIComponent(word)}&format=json&page=${pageNo}&pagesize=${pageSize}`
|
||||
const { statusCode, body } = await httpFetch(apiURL, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
|
||||
Referer: 'http://m.kugou.com',
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
|
||||
throw new Error(body?.error || '酷狗专辑搜索失败')
|
||||
}
|
||||
const list = (body?.data?.info ?? []).map((item: any) => {
|
||||
const albumID = String(item?.albumid ?? '').trim()
|
||||
if (!albumID || albumID === '0') return null
|
||||
return {
|
||||
id: albumID,
|
||||
name: String(item?.albumname ?? '').trim(),
|
||||
cover: albumCover(item?.imgurl),
|
||||
trackCount: Number(item?.songcount ?? 0) || 0,
|
||||
creator: String(item?.singername ?? '').trim(),
|
||||
description: String(item?.intro ?? '').trim(),
|
||||
publishTime: String(item?.publishtime ?? '').trim(),
|
||||
link: `https://www.kugou.com/album/${albumID}.html`,
|
||||
source: 'kg',
|
||||
type: 'album',
|
||||
}
|
||||
}).filter(Boolean)
|
||||
return { source: 'kg', list, page: pageNo, limit: pageSize, total: Number(body?.data?.total ?? 0) || list.length }
|
||||
},
|
||||
/** 上游 GetAlbumSongs:复用 getAlbumDetail 拉全量曲目 */
|
||||
async getAlbumSongs(id) {
|
||||
const albumID = String(id ?? '').trim()
|
||||
if (!albumID) throw new Error('酷狗专辑 id 为空')
|
||||
const { list } = await this.getAlbumDetail(albumID, 1, 10000)
|
||||
return list
|
||||
},
|
||||
/** 上游 ParseAlbum:album/single/<id>.html、album/<id>.html、albumid=<id> */
|
||||
async parseAlbumLink(link) {
|
||||
const raw = String(link ?? '')
|
||||
const patterns = [
|
||||
/album\/single\/(\d+)\.html/,
|
||||
/yy\/album\/single\/(\d+)\.html/,
|
||||
/album\/(\d+)\.html/,
|
||||
/albumid=(\d+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = raw.match(pattern)
|
||||
if (match) return this.getAlbumDetail(match[1], 1)
|
||||
}
|
||||
throw new Error('无效的酷狗专辑链接')
|
||||
},
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import { registerDfid } from '../auth/kg.ts'
|
||||
|
||||
const kg = {
|
||||
@@ -22,6 +24,8 @@ const kg = {
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
playlist,
|
||||
parse,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return kgApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ import { decodeName } from '../common/utils.ts'
|
||||
const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69])
|
||||
|
||||
const decodeLyric = str => new Promise((resolve, reject) => {
|
||||
if (!str.length) return
|
||||
// 空歌词必须显式 resolve:executor 里裸 return 会让 Promise 永久 pending,
|
||||
// 调用方的 await 将一直挂起(连接与内存无法释放,只能等客户端超时)
|
||||
if (!str.length) return resolve('')
|
||||
const buf_str = Buffer.from(str, 'base64').subarray(4)
|
||||
for (let i = 0, len = buf_str.length; i < len; i++) {
|
||||
buf_str[i] = buf_str[i] ^ enc_key[i % 16]
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@ export default {
|
||||
requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => {
|
||||
if (statusCode !== 200) {
|
||||
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
let tryRequestObj = (this.getLyric as any)(id, accessKey, fmt, ++tryNum)
|
||||
// 重试必须回到本方法 getLyricDownload;原先误调 getLyric(参数语义不同)导致重试恒失败
|
||||
const tryRequestObj = this.getLyricDownload(id, accessKey, fmt, ++tryNum)
|
||||
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
return tryRequestObj.promise
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// 酷狗链接解析
|
||||
// 歌曲:https://www.kugou.com/song/#hash=<32位hash>(含 &album_id=... 等尾串)、裸 hash
|
||||
// 上游对应:music-lib/kugou/song.go Parse / kugou.go fetchSongInfo
|
||||
// 元数据 m.kugou.com/app/i/getSongInfo.php(songName/author_name/timeLength/album_img/bitRate)
|
||||
// 另尽力合并 musicInfo.getMusicInfo 的专辑/多档 hash,直链取流失败不影响返回(上游为必得 URL)。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, decodeName } from '../index.ts'
|
||||
import kgApi from '../apis/kg.ts'
|
||||
import { getMusicInfo } from './musicInfo.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
|
||||
|
||||
const HASH_RE = /hash=([a-fA-F0-9]{32})/i
|
||||
const RAW_HASH_RE = /^[a-fA-F0-9]{32}$/
|
||||
|
||||
/** 链接/裸 hash → 32 位小写 hash(上游 Parse 的 `(?i)hash=([a-f0-9]{32})`,另接受裸 hash) */
|
||||
export const extractHash = (link: any): string => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const match = raw.match(HASH_RE)
|
||||
if (match) return match[1]!.toLowerCase()
|
||||
return RAW_HASH_RE.test(raw) ? raw.toLowerCase() : ''
|
||||
}
|
||||
|
||||
/** 上游 fetchSongInfo:m.kugou.com playInfo(返回元数据 + 直链,付费曲无 url) */
|
||||
const fetchSongInfoPhp = async(hash: string) => {
|
||||
const { statusCode, body } = await httpFetch(`http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`, {
|
||||
method: 'get',
|
||||
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || typeof body !== 'object' || body == null) return null
|
||||
if (Number(body.errcode ?? 0) !== 0 || !body.hash) return null
|
||||
return body
|
||||
}
|
||||
|
||||
/** 上游 Parse:酷狗歌曲链接(或裸 hash)→ Song,尽力附带直链 */
|
||||
export const parseSong = async(link: string) => {
|
||||
const hash = extractHash(link)
|
||||
if (!hash) throw new Error('无效的酷狗歌曲链接(未找到 32 位 hash)')
|
||||
|
||||
let [raw, info] = await Promise.all([
|
||||
fetchSongInfoPhp(hash).catch(() => null),
|
||||
getMusicInfo(hash).catch(() => undefined),
|
||||
])
|
||||
if (!raw && !info) throw new Error('酷狗歌曲不存在或已下架')
|
||||
|
||||
const fileName = String(raw?.fileName ?? '')
|
||||
const name = decodeName(String(raw?.songName ?? '').trim() || fileName.split(' - ').slice(1).join(' - ') || info?.name || '')
|
||||
const singer = decodeName(String(raw?.author_name ?? '').trim() || info?.singer || '')
|
||||
const duration = Number(raw?.timeLength ?? 0) || 0
|
||||
|
||||
const song: any = {
|
||||
songmid: String(info?.songmid ?? raw?.audio_id ?? '').trim(),
|
||||
hash: String(raw?.hash ?? hash).toLowerCase(),
|
||||
name,
|
||||
singer,
|
||||
albumName: decodeName(String(info?.albumName ?? '').trim()),
|
||||
albumId: String(info?.albumId ?? raw?.albumid ?? raw?.req_albumid ?? '').trim(),
|
||||
interval: duration > 0 ? formatPlayTime(duration) : (info?.interval ?? '--/--'),
|
||||
img: String(raw?.album_img ?? '').replace('{size}', '240') || info?.img || null,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types: info?.types ?? [],
|
||||
_types: info?._types ?? {},
|
||||
typeUrl: {},
|
||||
source: 'kg' as const,
|
||||
extra: {
|
||||
hash: String(raw?.hash ?? hash).toLowerCase(),
|
||||
url: String(raw?.url ?? ''),
|
||||
backup_url: raw?.backup_url ?? [],
|
||||
bitrate: Number(raw?.bitRate ?? 0) || 0,
|
||||
link: `https://www.kugou.com/song/#hash=${hash}`,
|
||||
},
|
||||
}
|
||||
|
||||
// 上游 fetchSongInfo 必得 URL:playInfo 的 url/backup_url 优先(官方直链,付费曲为空)
|
||||
if (!song.url) {
|
||||
const rawUrl = String(raw?.url ?? '') || String(raw?.backup_url?.[0] ?? '')
|
||||
if (rawUrl) {
|
||||
song.url = rawUrl
|
||||
song.type = Number(raw?.bitRate ?? 0) >= 320000 ? '320k' : '128k'
|
||||
}
|
||||
}
|
||||
// 仍无直链时(风控/付费)用 apis/kg 的签名通道尽力补一次;失败仅返回元数据
|
||||
if (!song.url) {
|
||||
try {
|
||||
const hit = await kgApi.getMusicUrl({
|
||||
hash: song.hash,
|
||||
albumId: song.albumId,
|
||||
songmid: song.songmid,
|
||||
_types: song._types,
|
||||
} as any, '128k').promise
|
||||
if (hit?.url) {
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
}
|
||||
} catch {
|
||||
// 忽略:与上游付费曲无直链的失败语义一致(元数据仍返回)
|
||||
}
|
||||
}
|
||||
|
||||
return song
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => album.parseAlbumLink(link)
|
||||
|
||||
export const parsePlaylist = async(link: string) => playlist.parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,237 @@
|
||||
// 酷狗歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
|
||||
// 上游对应:music-lib/kugou/playlist.go + kugou.go 的 fetchPlaylistDetail
|
||||
// 链接解析优先复用既有 sdk/kg/songList.ts 的 getUserListDetail / getListDetailBySpecialId /
|
||||
// getListInfoByChain / decodeGcid 等能力(覆盖 special/single、songlist/gcid、share chain、
|
||||
// zlist、global_collection_id 等形态),本模块只做统一形状归一。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import songList from './songList.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
|
||||
|
||||
const firstNonEmpty = (...values: any[]): string => {
|
||||
for (const value of values) {
|
||||
if (value == null) continue
|
||||
const str = String(value).trim()
|
||||
if (str) return str
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const playlistCover = (url: any): string => String(url ?? '').replace('{size}', '240')
|
||||
|
||||
/** 歌单摘要(对齐契约形状;id 用平台 specialid,link 指向网页版) */
|
||||
const toSummary = (item: any) => {
|
||||
const rawId = firstNonEmpty(item?.specialid, item?.global_specialid, item?.id)
|
||||
const id = rawId.replace(/^id_/, '')
|
||||
if (!id) return null
|
||||
const playCount = Number(item?.playcount)
|
||||
return {
|
||||
id,
|
||||
name: firstNonEmpty(item?.specialname, item?.name),
|
||||
cover: playlistCover(firstNonEmpty(item?.imgurl, item?.img)),
|
||||
trackCount: Number(item?.songcount ?? item?.total ?? 0) || 0,
|
||||
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
|
||||
creator: firstNonEmpty(item?.nickname, item?.username, item?.author),
|
||||
description: firstNonEmpty(item?.intro, item?.desc),
|
||||
publishTime: String(item?.publishtime ?? item?.time ?? '').trim(),
|
||||
link: `https://www.kugou.com/yy/special/single/${id}.html`,
|
||||
source: 'kg' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 msearchretry v3 接口) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
if (!word) return { source: 'kg' as const, list: [] as any[], page, limit, total: 0 }
|
||||
const res = await songList.search(word, page, limit)
|
||||
const list = (res?.list ?? []).map(toSummary).filter(Boolean)
|
||||
return {
|
||||
source: 'kg' as const,
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total: Number(res?.total ?? 0) || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 数字 id / id_ 前缀 / 链接 → songList.getListDetail 可识别的入参 */
|
||||
const normalizePlaylistId = (id: any): string => {
|
||||
const raw = String(id ?? '').trim()
|
||||
if (!raw) return ''
|
||||
if (/^\d+$/.test(raw)) return `id_${raw}`
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌单详情(含元数据与曲目)
|
||||
* 委托 songList.getListDetail(special 页 / gcid songlist / share chain / 裸酷狗码等),
|
||||
* 平台自身返回全量时按 page/limit 本地开窗。
|
||||
*/
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const requestId = normalizePlaylistId(id)
|
||||
if (!requestId) throw new Error('酷狗歌单 id 为空')
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 50)
|
||||
|
||||
const res = await songList.getListDetail(requestId, pageNo)
|
||||
if (!res) throw new Error('酷狗歌单不存在或解析失败')
|
||||
let list: any[] = res.list ?? []
|
||||
const total = Number(res.total ?? 0) || list.length
|
||||
if (list.length > pageSize) {
|
||||
const start = (pageNo - 1) * pageSize
|
||||
list = list.slice(start, start + pageSize)
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'kg' as const,
|
||||
info: {
|
||||
name: firstNonEmpty(res.info?.name, String(id)),
|
||||
img: playlistCover(res.info?.img),
|
||||
desc: firstNonEmpty(res.info?.desc),
|
||||
author: firstNonEmpty(res.info?.author),
|
||||
play_count: res.info?.play_count ?? '',
|
||||
},
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistSongs:只取曲目(复用详情全量) */
|
||||
export const getPlaylistSongs = async(id: string) => {
|
||||
const res = await songList.getListDetail(normalizePlaylistId(id), 1)
|
||||
return res?.list ?? []
|
||||
}
|
||||
|
||||
/** 歌单分类(上游 GetPlaylistCategories:tag/list;categoryId 形如 `<id>:<tagId>`) */
|
||||
export const getCategories = async() => {
|
||||
const { statusCode, body } = await httpFetch('http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0', {
|
||||
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
|
||||
throw new Error(body?.error || '酷狗歌单分类获取失败')
|
||||
}
|
||||
|
||||
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
|
||||
{ id: '', name: '全部', group: '全部' },
|
||||
]
|
||||
for (const group of body?.data?.info ?? []) {
|
||||
const groupName = String(group?.name ?? '').trim()
|
||||
const groupID = Number(group?.id ?? 0) || 0
|
||||
if (groupID > 0 && groupName) {
|
||||
list.push({ id: `${groupID}:0`, name: groupName, group: groupName })
|
||||
}
|
||||
for (const child of group?.children ?? []) {
|
||||
const id = Number(child?.id ?? 0) || 0
|
||||
const name = String(child?.name ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
const tagId = Number(child?.special_tag_id ?? 0) || 0
|
||||
list.push({ id: `${id}:${tagId}`, name, group: groupName, hot: Number(child?.is_hot ?? 0) === 1 })
|
||||
}
|
||||
}
|
||||
return { source: 'kg' as const, list }
|
||||
}
|
||||
|
||||
/** categoryId → [id, tagId];空/dirty 时取分类表首个非空分类 */
|
||||
const parseCategoryID = (categoryId: any): { id: string, tagId: string } => {
|
||||
const parts = String(categoryId ?? '').trim().split(':')
|
||||
const id = String(parts[0] ?? '').trim()
|
||||
const tagId = String(parts[1] ?? '').trim() || '0'
|
||||
return { id, tagId }
|
||||
}
|
||||
|
||||
/** 分类歌单(上游 GetCategoryPlaylists:tag/specialList) */
|
||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 20)
|
||||
|
||||
let { id, tagId } = parseCategoryID(categoryId)
|
||||
if (!id) {
|
||||
const { list } = await getCategories()
|
||||
const first = list.find(item => item.id)
|
||||
if (!first) throw new Error('酷狗歌单分类为空')
|
||||
;({ id, tagId } = parseCategoryID(first.id))
|
||||
}
|
||||
|
||||
const apiURL = 'http://mobilecdnbj.kugou.com/api/v3/tag/specialList?' + [
|
||||
'plat=0',
|
||||
`page=${pageNo}`,
|
||||
`tagid=${tagId}`,
|
||||
`pagesize=${pageSize}`,
|
||||
'ugc=1',
|
||||
`id=${id}`,
|
||||
'sort=2',
|
||||
].join('&')
|
||||
const { statusCode, body } = await httpFetch(apiURL, {
|
||||
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
|
||||
throw new Error(body?.error || '酷狗分类歌单获取失败')
|
||||
}
|
||||
|
||||
const list = (body?.data?.info ?? []).map((item: any) => {
|
||||
const summary: any = toSummary(item) ?? toSummary({ ...item, specialid: item?.global_specialid })
|
||||
if (!summary) return null
|
||||
summary.categoryId = `${id}:${tagId}`
|
||||
return summary
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
source: 'kg' as const,
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total: Number(body?.data?.total ?? 0) || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 推荐歌单(上游 GetRecommendedPlaylists:m.kugou.com/plist/index&json=true) */
|
||||
export const getRecommended = async(page = 1, limit = 30) => {
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 30)
|
||||
const { statusCode, body } = await httpFetch(`http://m.kugou.com/plist/index&json=true&page=${pageNo}`, {
|
||||
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || typeof body !== 'object' || body == null) {
|
||||
throw new Error('酷狗推荐歌单获取失败')
|
||||
}
|
||||
let list = (body?.plist?.list?.info ?? []).map(toSummary).filter(Boolean)
|
||||
if (list.length > pageSize) list = list.slice(0, pageSize)
|
||||
return {
|
||||
source: 'kg' as const,
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total: Number(body?.plist?.list?.total ?? 0) || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游 ParsePlaylist:
|
||||
* /yy/special/single/<id>.html → special 详情
|
||||
* /songlist/<gcid_xxx>/ → gcid 详情
|
||||
* 其余链接形态(share chain、zlist、global_collection_id、裸 id/酷狗码)交给 songList 通用解析。
|
||||
*/
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) throw new Error('无效的酷狗歌单链接')
|
||||
const special = raw.match(/special\/single\/(\d+)\.html/)
|
||||
const gcid = raw.match(/songlist\/(gcid_[A-Za-z0-9]+)/)
|
||||
if (special) return getPlaylistDetail(special[1]!, 1)
|
||||
if (gcid) return getPlaylistDetail(gcid[1]!, 1)
|
||||
return getPlaylistDetail(raw, 1)
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
getRecommended,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
+133
@@ -3,6 +3,15 @@ import { decodeName } from '../index.ts'
|
||||
import { formatSinger, objStr2JSON } from './util.ts'
|
||||
import type { Quality } from '../types.ts'
|
||||
|
||||
const firstNonEmpty = (...values: any[]): string => {
|
||||
for (const value of values) {
|
||||
if (value == null) continue
|
||||
const str = String(value).trim()
|
||||
if (str) return str
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// let requestObj_list
|
||||
export default {
|
||||
limit_list: 36,
|
||||
@@ -102,6 +111,130 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
/**
|
||||
* 专辑详情(统一形状;服务端 1000 条/页,limit 更小时本地按 page/limit 开窗)
|
||||
* 上游对应:kuwo.go fetchAlbumDetail(legacy API 部分)
|
||||
*/
|
||||
async getAlbumDetail(id: string, page = 1, limit = 1000) {
|
||||
const albumID = String(id ?? '').trim()
|
||||
if (!albumID) throw new Error('酷我专辑 id 为空')
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || this.limit_song)
|
||||
if (pageSize >= this.limit_song) return this.getAlbumListDetail(albumID, pageNo)
|
||||
|
||||
const start = (pageNo - 1) * pageSize
|
||||
let serverPage = Math.floor(start / this.limit_song) + 1
|
||||
let skip = start % this.limit_song
|
||||
const list: any[] = []
|
||||
let info: any = null
|
||||
let total = 0
|
||||
while (list.length < pageSize && serverPage <= 50) {
|
||||
const res = await this.getAlbumListDetail(albumID, serverPage)
|
||||
if (!res) break
|
||||
if (!info) info = res.info ?? null
|
||||
total = Number(res.total ?? 0) || total
|
||||
const rawList: any[] = res.list ?? []
|
||||
if (!rawList.length) break
|
||||
list.push(...rawList.slice(skip, skip + (pageSize - list.length)))
|
||||
skip = 0
|
||||
if (rawList.length < this.limit_song) break
|
||||
if (total > 0 && list.length >= total) break
|
||||
serverPage += 1
|
||||
}
|
||||
return {
|
||||
source: 'kw',
|
||||
info: info ?? { name: albumID, img: '', desc: '', author: '' },
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total: total || list.length,
|
||||
}
|
||||
},
|
||||
/** 上游 GetAlbumSongs:聚合全部分页曲目(去重) */
|
||||
async getAlbumSongs(id: string) {
|
||||
const albumID = String(id ?? '').trim()
|
||||
if (!albumID) throw new Error('酷我专辑 id 为空')
|
||||
const songs: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let page = 1; page <= 50; page++) {
|
||||
const res = await this.getAlbumListDetail(albumID, page)
|
||||
const rawList: any[] = res?.list ?? []
|
||||
for (const song of rawList) {
|
||||
const key = String(song?.songmid ?? '')
|
||||
if (key && seen.has(key)) continue
|
||||
if (key) seen.add(key)
|
||||
songs.push(song)
|
||||
}
|
||||
const total = Number(res?.total ?? 0) || 0
|
||||
if (rawList.length < this.limit_song) break
|
||||
if (total > 0 && songs.length >= total) break
|
||||
}
|
||||
return songs
|
||||
},
|
||||
/**
|
||||
* 专辑搜索(上游 SearchAlbum,legacy r.s ft=album 路由;返回歌单/专辑统一摘要形状)
|
||||
*/
|
||||
async searchAlbum(keyword: string, page = 1, limit = 10) {
|
||||
const word = String(keyword ?? '').trim()
|
||||
if (!word) return { source: 'kw' as const, list: [] as any[], page, limit, total: 0 }
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 10)
|
||||
const apiURL = 'http://search.kuwo.cn/r.s?' + [
|
||||
`all=${encodeURIComponent(word)}`,
|
||||
'ft=album',
|
||||
'itemset=web_2013',
|
||||
'client=kt',
|
||||
'pcmp4=1',
|
||||
'geo=c',
|
||||
'vipver=1',
|
||||
`pn=${pageNo - 1}`,
|
||||
`rn=${pageSize}`,
|
||||
'rformat=json',
|
||||
'encoding=utf8',
|
||||
].join('&')
|
||||
|
||||
const { statusCode, body } = await httpFetch(apiURL).promise
|
||||
if (statusCode !== 200) throw new Error('酷我专辑搜索失败')
|
||||
const data = typeof body === 'string' ? (() => { try { return objStr2JSON(body) } catch { return null } })() : body
|
||||
if (!data?.albumlist) throw new Error('酷我专辑搜索失败')
|
||||
|
||||
const list = (data.albumlist as any[]).map(item => {
|
||||
const albumID = firstNonEmpty(item.albumid, item.id)
|
||||
if (!albumID) return null
|
||||
return {
|
||||
id: albumID,
|
||||
name: decodeName(String(item.name ?? '').trim()),
|
||||
cover: firstNonEmpty(item.hts_img, item.img),
|
||||
trackCount: parseInt(String(item.musiccnt ?? ''), 10) || 0,
|
||||
playCount: parseInt(String(item.PLAYCNT ?? item.playcnt ?? ''), 10) || 0,
|
||||
creator: decodeName(firstNonEmpty(item.aartist, item.artist)),
|
||||
description: decodeName(String(item.info ?? '').trim()),
|
||||
link: `http://www.kuwo.cn/album_detail/${albumID}`,
|
||||
source: 'kw' as const,
|
||||
type: 'album',
|
||||
company: decodeName(String(item.company ?? '').trim()),
|
||||
publishTime: String(item.pub ?? '').trim(),
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return {
|
||||
source: 'kw' as const,
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total: parseInt(String(data.TOTAL ?? data.total ?? ''), 10) || list.length,
|
||||
}
|
||||
},
|
||||
/** 上游 ParseAlbum:album_detail/<id>、album/<id>、albumid=<id> */
|
||||
async parseAlbumLink(link: string) {
|
||||
const raw = String(link ?? '')
|
||||
const patterns = [/album_detail\/(\d+)/, /album\/(\d+)/, /albumid=(\d+)/]
|
||||
for (const pattern of patterns) {
|
||||
const match = raw.match(pattern)
|
||||
if (match) return this.getAlbumDetail(match[1]!, 1)
|
||||
}
|
||||
throw new Error('无效的酷我专辑链接')
|
||||
},
|
||||
// getAlbumListDetail(id, page, retryNum = 0) {
|
||||
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import songList from './songList.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import type { HttpRequestObject } from '../types.ts'
|
||||
|
||||
const kw = {
|
||||
@@ -17,6 +19,8 @@ const kw = {
|
||||
_musicPicRequestObj: null as HttpRequestObject | null,
|
||||
_musicPicPromiseCancelFn: null,
|
||||
album,
|
||||
playlist,
|
||||
parse,
|
||||
// context: null,
|
||||
|
||||
|
||||
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
// 酷我链接解析
|
||||
// 歌曲:https://www.kuwo.cn/play_detail/<rid>(含 ?/# 尾串)、m.kuwo.cn 的 musicId=<rid>、裸 rid
|
||||
// 上游对应:music-lib/kuwo/song.go Parse / kuwo.go fetchFullSongInfo(元数据 m.kuwo.cn + 尽力附直链)
|
||||
// 专辑 / 歌单分别复用 album.parseAlbumLink、playlist.parsePlaylistLink。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime } from '../index.ts'
|
||||
import kwApi from '../apis/kw.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import { formatSinger } from './util.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
|
||||
|
||||
const SONG_PATTERNS = [
|
||||
/play_detail\/(\d+)/, // http://www.kuwo.cn/play_detail/228908
|
||||
/h5app\/song\/(\d+)/, // https://m.kuwo.cn/h5app/song/228908
|
||||
/musicId=(\d+)/, // http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=228908
|
||||
/^\d{4,}$/, // 裸 rid
|
||||
]
|
||||
|
||||
const extractSongId = (link: any): string => {
|
||||
const raw = String(link ?? '').trim()
|
||||
for (const pattern of SONG_PATTERNS) {
|
||||
const match = raw.match(pattern)
|
||||
if (match) return match[1] ?? raw
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
interface KuwoSongMeta {
|
||||
songName?: string
|
||||
artist?: string
|
||||
pic?: string
|
||||
album?: string
|
||||
albumId?: string | number | null
|
||||
duration?: string | number
|
||||
}
|
||||
|
||||
/** 拉取歌曲元数据(上游 fetchFullSongInfo 的 songinfoandlrc 部分;接口偶发 status 301,重试 3 次) */
|
||||
const fetchSongMeta = async(rid: string, retryNum = 0): Promise<KuwoSongMeta> => {
|
||||
const { body } = await httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${rid}&httpsStatus=1`, {
|
||||
method: 'get',
|
||||
headers: { 'User-Agent': UA, Cookie: 'wd=1;vip=0' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
|
||||
let data: any = body
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch {
|
||||
data = null
|
||||
}
|
||||
}
|
||||
const songinfo = data?.data?.songinfo
|
||||
if (!songinfo) {
|
||||
// 风控/抖动时上游返回 status 301「音乐查询失败」,退避重试(最多 5 次,约 9s)
|
||||
if (retryNum < 5) {
|
||||
await new Promise(resolve => setTimeout(resolve, 600 * (retryNum + 1)))
|
||||
return fetchSongMeta(rid, ++retryNum)
|
||||
}
|
||||
throw new Error(data?.msg || '酷我歌曲不存在或已下架')
|
||||
}
|
||||
return songinfo
|
||||
}
|
||||
|
||||
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type,失败不影响返回) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const rid = extractSongId(link)
|
||||
if (!rid) throw new Error('无效的酷我歌曲链接')
|
||||
|
||||
const meta = await fetchSongMeta(rid)
|
||||
const duration = parseInt(String(meta.duration ?? ''), 10) || 0
|
||||
const song: any = {
|
||||
songmid: rid,
|
||||
name: meta.songName || `Kuwo_Song_${rid}`,
|
||||
singer: formatSinger(String(meta.artist ?? '').trim()),
|
||||
albumName: String(meta.album ?? '').trim(),
|
||||
albumId: meta.albumId != null ? String(meta.albumId) : '',
|
||||
interval: duration > 0 ? formatPlayTime(duration) : '--/--',
|
||||
img: meta.pic || null,
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types: [],
|
||||
_types: {},
|
||||
typeUrl: {},
|
||||
source: 'kw' as const,
|
||||
extra: { rid, link: `http://www.kuwo.cn/play_detail/${rid}` },
|
||||
}
|
||||
|
||||
try {
|
||||
const hit = await kwApi.getMusicUrl({ songmid: rid } as any, '128k').promise
|
||||
if (hit?.url) {
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
}
|
||||
} catch {
|
||||
// 与上游 fetchAudioURL 降级一致:直链获取失败(版权/风控)仍返回元数据
|
||||
}
|
||||
|
||||
return song
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => album.parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => playlist.parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,217 @@
|
||||
// 酷我歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
|
||||
// 上游对应:music-lib/kuwo/playlist.go
|
||||
// 复用既有内部实现(不重复造轮子):
|
||||
// - 搜索:songList.search
|
||||
// - 详情:songList.getListDetail(nplserver,服务端固定 1000 条/页,本地按 page/limit 开窗)
|
||||
// - 分类:songList.getTag / getHotTag(同上游 getTagList / getRcmTagList)
|
||||
// - 分类歌单 / 推荐:songList.getList(wapi getTagPlayList / getRcmPlayList)
|
||||
// 上游 GetRecommendedPlaylists 用 getRcmPlayList(order=hot) 且 limit=30;既有 getList 的
|
||||
// limit_list=36(模块常量),故推荐条数以 36 为准,显式 limit 更小时本地截断。
|
||||
import songList from './songList.ts'
|
||||
|
||||
// songList.getListDetailUrl 固定 rn=limit_song=1000
|
||||
const SERVER_PAGE_SIZE = 1000
|
||||
const MAX_SERVER_PAGES = 60
|
||||
|
||||
const firstNonEmpty = (...values: any[]): string => {
|
||||
for (const value of values) {
|
||||
if (value == null) continue
|
||||
const str = String(value).trim()
|
||||
if (str) return str
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 歌单链接 / digest 前缀 id → 平台内部 id(digest 前缀形态原样保留,交给 songList 分流) */
|
||||
const normalizePlaylistId = (id: any): string => {
|
||||
const raw = String(id ?? '').trim()
|
||||
if (!raw) return ''
|
||||
const patterns = [
|
||||
/playlist_detail\/(\d+)/, // http://www.kuwo.cn/playlist_detail/2886046289
|
||||
/\/playlist\/(\d+)/, // https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
|
||||
/playlistId=(\d+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = raw.match(pattern)
|
||||
if (match) return match[1]!
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** 歌单摘要(对齐契约形状:id/name/cover/trackCount/playCount/creator/description/link/source) */
|
||||
const toSummary = (item: any) => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
const plainId = id.replace(/^digest-\w+__/, '')
|
||||
const playCount = Number(item?.play_count)
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
name: String(item?.name ?? '').trim(),
|
||||
cover: firstNonEmpty(item?.img, item?.cover),
|
||||
trackCount: Number(item?.total ?? item?.trackCount ?? 0) || 0,
|
||||
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
|
||||
creator: firstNonEmpty(item?.author, item?.creator, item?.uname),
|
||||
description: firstNonEmpty(item?.desc, item?.description),
|
||||
link: plainId ? `http://www.kuwo.cn/playlist_detail/${plainId}` : '',
|
||||
source: 'kw' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 legacy r.s 接口) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
if (!word) return { source: 'kw' as const, list: [] as any[], page, limit, total: 0 }
|
||||
const res = await songList.search(word, page, limit)
|
||||
const list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
|
||||
return {
|
||||
source: 'kw' as const,
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total: Number(res?.total ?? 0) || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌单详情(含元数据与曲目)
|
||||
* 服务端 1000 条/页,按调用方 page/limit 开窗,只抓所需的 1~2 个服务端页。
|
||||
*/
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const playlistId = normalizePlaylistId(id)
|
||||
if (!playlistId) throw new Error('酷我歌单 id 为空')
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.max(1, Number(limit) || 50)
|
||||
const start = (pageNo - 1) * pageSize
|
||||
|
||||
let serverPage = Math.floor(start / SERVER_PAGE_SIZE) + 1
|
||||
let skip = start % SERVER_PAGE_SIZE
|
||||
const list: any[] = []
|
||||
let info: any = null
|
||||
let total = 0
|
||||
|
||||
while (list.length < pageSize && serverPage <= MAX_SERVER_PAGES) {
|
||||
const res = await songList.getListDetail(playlistId, serverPage)
|
||||
if (!res) break
|
||||
if (!info) info = res.info ?? null
|
||||
total = Number(res.total ?? 0) || total
|
||||
const rawList: any[] = res.list ?? []
|
||||
if (!rawList.length) break
|
||||
list.push(...rawList.slice(skip, skip + (pageSize - list.length)))
|
||||
skip = 0
|
||||
if (rawList.length < SERVER_PAGE_SIZE) break
|
||||
if (total > 0 && serverPage * SERVER_PAGE_SIZE >= total) break
|
||||
serverPage += 1
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'kw' as const,
|
||||
info: {
|
||||
name: firstNonEmpty(info?.name, playlistId),
|
||||
img: firstNonEmpty(info?.img),
|
||||
desc: firstNonEmpty(info?.desc),
|
||||
author: firstNonEmpty(info?.author),
|
||||
play_count: info?.play_count ?? '',
|
||||
},
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
total: total || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistSongs:聚合全部页曲目(去重) */
|
||||
export const getPlaylistSongs = async(id: string) => {
|
||||
const playlistId = normalizePlaylistId(id)
|
||||
if (!playlistId) throw new Error('酷我歌单 id 为空')
|
||||
const songListResult: any[] = []
|
||||
const seen = new Set<string>()
|
||||
for (let serverPage = 1; serverPage <= MAX_SERVER_PAGES; serverPage++) {
|
||||
const res = await songList.getListDetail(playlistId, serverPage)
|
||||
const rawList: any[] = res?.list ?? []
|
||||
for (const song of rawList) {
|
||||
const key = String(song?.songmid ?? '')
|
||||
if (key && seen.has(key)) continue
|
||||
if (key) seen.add(key)
|
||||
songListResult.push(song)
|
||||
}
|
||||
const total = Number(res?.total ?? 0) || 0
|
||||
if (rawList.length < SERVER_PAGE_SIZE) break
|
||||
if (total > 0 && songListResult.length >= total) break
|
||||
}
|
||||
return songListResult
|
||||
}
|
||||
|
||||
/** 歌单分类(上游 GetPlaylistCategories;复用 getTag/getHotTag,tagId 形如 `<id>-<digest>`) */
|
||||
export const getCategories = async() => {
|
||||
const { tags, hotTag } = await songList.getTags()
|
||||
const hotIds = new Set((hotTag ?? []).map((item: any) => String(item?.id ?? '')))
|
||||
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
|
||||
{ id: '', name: '全部', group: '全部' },
|
||||
]
|
||||
for (const group of tags ?? []) {
|
||||
for (const item of group?.list ?? []) {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
const name = String(item?.name ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
list.push({ id, name, group: String(group?.name ?? '').trim() || '其他', hot: hotIds.has(id) })
|
||||
}
|
||||
}
|
||||
return { source: 'kw' as const, list }
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类歌单(上游 GetCategoryPlaylists)
|
||||
* categoryId 为空 → getRcmPlayList(order=hot);否则按 `<id>-<digest>` 走 getTagPlayList。
|
||||
*/
|
||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||
const tagId = String(categoryId ?? '').trim()
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const res = await songList.getList('hot', tagId || undefined, pageNo)
|
||||
let list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
|
||||
const requested = Number(limit) || 0
|
||||
const serverLimit = Number(res?.limit ?? 0) || 0
|
||||
if (requested > 0 && list.length > requested) list = list.slice(0, requested)
|
||||
return {
|
||||
source: 'kw' as const,
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: (requested > 0 && serverLimit > 0 ? Math.min(requested, serverLimit) : serverLimit) || list.length,
|
||||
total: Number(res?.total ?? 0) || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 推荐歌单(上游 GetRecommendedPlaylists;复用 getList(order=hot)) */
|
||||
export const getRecommended = async(page = 1, limit = 30) => {
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const res = await songList.getList('hot', undefined, pageNo)
|
||||
let list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
|
||||
const requested = Number(limit) || 0
|
||||
const serverLimit = Number(res?.limit ?? 0) || 0
|
||||
if (requested > 0 && list.length > requested) list = list.slice(0, requested)
|
||||
return {
|
||||
source: 'kw' as const,
|
||||
list,
|
||||
page: pageNo,
|
||||
limit: (requested > 0 && serverLimit > 0 ? Math.min(requested, serverLimit) : serverLimit) || list.length,
|
||||
total: Number(res?.total ?? 0) || list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 ParsePlaylist:playlist_detail/<id>、m.kuwo.cn/h5app/playlist/<id>(含 ?/# 尾串) */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const match = String(link ?? '').match(/playlist_detail\/(\d+)/) ||
|
||||
String(link ?? '').match(/\/playlist\/(\d+)/) ||
|
||||
String(link ?? '').match(/playlistId=(\d+)/)
|
||||
if (!match) throw new Error('无效的酷我歌单链接')
|
||||
return getPlaylistDetail(match[1]!, 1)
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
getRecommended,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
+4
-2
@@ -193,14 +193,16 @@ export default {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj = httpFetch(`http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node=${id}&pn=0&rn=1&fmt=json&src=mbox&level=2`)
|
||||
return requestObj.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || !body.child) return this.getListDetail(id, ++tryNum)
|
||||
// 本方法须返回 detailId(调用方据此再查曲目),失败时应重试自身;
|
||||
// 原先误调 getListDetail(返回歌单详情对象、且把 tryNum 当 page)导致返回类型错位
|
||||
if (statusCode != 200 || !body.child) return this.getListDetailDigest5Info(id, ++tryNum)
|
||||
// console.log(body)
|
||||
return body.child.length ? body.child[0].sourceid : null
|
||||
})
|
||||
},
|
||||
getListDetailDigest5Music(id, page, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
||||
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
|
||||
|
||||
+2
-1
@@ -50,5 +50,6 @@ const lr = {
|
||||
},
|
||||
}
|
||||
|
||||
export { status } from './auth.ts'
|
||||
// login 一并导出:/login/lr 路由直接引用 sdk/lr/auth.ts,SDK 使用者也需要能拿到登录能力
|
||||
export { login, status } from './auth.ts'
|
||||
export default lr
|
||||
@@ -0,0 +1,130 @@
|
||||
// 咪咕专辑 —— 搜索(search_all.do album 开关)+ 曲目(queryAlbumSong,分页聚合去重)+ 详情(resourceinfo 2003)
|
||||
// 上游对应:music-lib/migu/album.go、migu.go 的 fetchAlbumDetail / fetchAlbumSongs
|
||||
import {
|
||||
buildUrl, convertItemToSong, firstNonEmpty, miguGet, normalizeMiguImageURL,
|
||||
pickMiguImage, type MiguImageItem, type MiguSong, type MiguSongItem, miguAlbumLink,
|
||||
} from './util.ts'
|
||||
import { searchAll } from './musicSearch.ts'
|
||||
|
||||
interface MiguAlbumSearchItem {
|
||||
id?: string
|
||||
resourceType?: string
|
||||
name?: string
|
||||
singer?: string
|
||||
publishDate?: string
|
||||
desc?: string
|
||||
imgItems?: MiguImageItem[]
|
||||
}
|
||||
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const body = await searchAll(keyword, 'album', page, limit)
|
||||
const result: MiguAlbumSearchItem[] = body?.albumResultData?.result ?? []
|
||||
const list = result.map(item => {
|
||||
const albumID = String(item.id ?? '').trim()
|
||||
if (!albumID) return null
|
||||
return {
|
||||
id: albumID,
|
||||
name: String(item.name ?? '').trim(),
|
||||
cover: pickMiguImage(item.imgItems),
|
||||
creator: String(item.singer ?? '').trim(),
|
||||
description: firstNonEmpty(item.desc, item.publishDate),
|
||||
link: miguAlbumLink(albumID),
|
||||
source: 'migu' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
return { list, source: 'migu' as const, page, limit, total: list.length }
|
||||
}
|
||||
|
||||
/** 专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs) */
|
||||
export const fetchAlbumSongs = async(albumId: string): Promise<{ songs: MiguSong[], total: number }> => {
|
||||
const albumID = String(albumId ?? '').trim()
|
||||
if (!albumID) throw new Error('migu 专辑 id 为空')
|
||||
const pageSize = 50
|
||||
const seen = new Set<string>()
|
||||
const songs: MiguSong[] = []
|
||||
let totalCount = 0
|
||||
|
||||
for (let pageNo = 1; ; pageNo++) {
|
||||
const body = await miguGet(buildUrl('https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong', {
|
||||
albumId: albumID, pageNo, pageSize,
|
||||
}))
|
||||
if (body?.code && body.code !== '000000') throw new Error(`migu api error: ${body.info} (code ${body.code})`)
|
||||
const songList: MiguSongItem[] = body?.data?.songList ?? []
|
||||
if (!totalCount) totalCount = Number(body?.data?.totalCount ?? 0) || 0
|
||||
if (!songList.length) break
|
||||
|
||||
const before = songs.length
|
||||
for (const item of songList) {
|
||||
const song = convertItemToSong(item, true)
|
||||
if (!song) continue
|
||||
const key = firstNonEmpty(song.extra.content_id, item.contentId, song.songmid, item.copyrightId)
|
||||
|| `${song.name}|${song.singer}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
songs.push(song)
|
||||
}
|
||||
|
||||
if (songList.length < pageSize) break
|
||||
if (totalCount > 0 && songs.length >= totalCount) break
|
||||
if (songs.length === before) break
|
||||
}
|
||||
|
||||
if (!songs.length) throw new Error('migu 专辑没有可播放歌曲')
|
||||
return { songs, total: totalCount || songs.length }
|
||||
}
|
||||
|
||||
/** 专辑详情(曲目 + resourceinfo 2003 的元数据),对齐既有 kg/kw 的 getAlbumDetail 返回形状 */
|
||||
export const getAlbumDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const albumID = String(id ?? '').trim()
|
||||
if (!albumID) throw new Error('migu 专辑 id 为空')
|
||||
const { songs, total } = await fetchAlbumSongs(albumID)
|
||||
|
||||
let info: { name: string, img: string, desc: string, author: string } = { name: albumID, img: '', desc: '', author: '' }
|
||||
try {
|
||||
const body = await miguGet(buildUrl('https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do', {
|
||||
needSimple: '00', resourceType: '2003', resourceId: albumID,
|
||||
}))
|
||||
if (body?.code && body.code !== '000000') throw new Error(body.info)
|
||||
const res = body?.resource?.[0]
|
||||
if (res) {
|
||||
info = {
|
||||
name: String(res.title ?? '').trim() || albumID,
|
||||
img: pickMiguImage(res.imgItems),
|
||||
desc: String(res.summary ?? '').trim(),
|
||||
author: String(res.singer ?? '').trim(),
|
||||
}
|
||||
if (!info.img && songs[0]?.img) info.img = songs[0].img!
|
||||
}
|
||||
} catch {
|
||||
if (songs[0]?.img) info.img = songs[0].img!
|
||||
}
|
||||
|
||||
const start = (page - 1) * limit
|
||||
return {
|
||||
source: 'migu' as const,
|
||||
info,
|
||||
list: songs.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: total || songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAlbumSongs = async(id: string) => (await fetchAlbumSongs(id)).songs
|
||||
|
||||
const ALBUM_PATTERNS = [
|
||||
/music\.migu\.cn\/(?:v3|v5)\/music\/album\/(\d+)/,
|
||||
/albumId=(\d+)/,
|
||||
/resourceId=(\d+)/,
|
||||
]
|
||||
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
for (const pattern of ALBUM_PATTERNS) {
|
||||
const m = String(link ?? '').match(pattern)
|
||||
if (m) return getAlbumDetail(m[1]!)
|
||||
}
|
||||
throw new Error('无效的咪咕专辑链接')
|
||||
}
|
||||
|
||||
export { normalizeMiguImageURL }
|
||||
export default { searchAlbum, getAlbumDetail, getAlbumSongs, parseAlbumLink }
|
||||
@@ -0,0 +1,63 @@
|
||||
// 咪咕音乐平台模块 —— 搜索 / 取流(多档候选 + Z3D 解密标注)/ 歌词 / 封面 / 专辑 / 歌单 / 链接解析
|
||||
// 上游对应:music-lib/migu/(migu.go、song.go、lyric.go、album.go、playlist.go、download.go)
|
||||
// 匿名面(2026-10-02 实测):搜索、PQ(128k mp3) 取流、歌词、封面、专辑曲目、歌单曲目均可用;
|
||||
// HQ/SQ/ZQ 各档需登录 Cookie(接口返回「登录后免费试听」)。
|
||||
import miguApi from '../apis/migu.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric, { contentIdOf } from './lyric.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import { fetchSongDetail, miguSongLink } from './util.ts'
|
||||
|
||||
const migu = {
|
||||
musicSearch,
|
||||
getMusicUrl(songInfo: any, type: string) {
|
||||
return miguApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
getLyric(songInfo: any) {
|
||||
return lyric.getLyric(songInfo)
|
||||
},
|
||||
getPic(songInfo: any) {
|
||||
return (async() => {
|
||||
if (songInfo?.img) return songInfo.img
|
||||
const contentID = contentIdOf(songInfo)
|
||||
if (!contentID) return null
|
||||
const item = await fetchSongDetail(contentID)
|
||||
const { pickMiguImage, normalizeMiguImageURL, firstNonEmpty } = await import('./util.ts')
|
||||
return normalizeMiguImageURL(firstNonEmpty(
|
||||
pickMiguImage(item.imgItems), pickMiguImage(item.albumImgs), item.img1, item.img2, item.img3,
|
||||
)) || null
|
||||
})()
|
||||
},
|
||||
getMusicInfo(songInfo: any) {
|
||||
const contentID = contentIdOf(songInfo)
|
||||
if (!contentID) return Promise.reject(new Error('migu 歌曲 id 无效'))
|
||||
return fetchSongDetail(contentID)
|
||||
},
|
||||
album,
|
||||
playlist,
|
||||
parse: {
|
||||
parseSong: async(link: string) => parseSong(link),
|
||||
parseAlbum: album.parseAlbumLink,
|
||||
parsePlaylist: playlist.parsePlaylistLink,
|
||||
},
|
||||
getMusicDetailPageUrl(songInfo: any) {
|
||||
const extra = songInfo?.extra ?? {}
|
||||
return String(extra.link ?? '') || miguSongLink(contentIdOf(songInfo))
|
||||
},
|
||||
}
|
||||
|
||||
/** 歌曲链接解析:https://music.migu.cn/v3/music/song/<contentId>(含裸 contentId) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '')
|
||||
const m = raw.match(/music\.migu\.cn\/v3\/music\/song\/(\d+)/) || raw.match(/^(\d{6,})$/)
|
||||
if (!m) throw new Error('无效的咪咕歌曲链接')
|
||||
const contentID = m[1]!
|
||||
const item = await fetchSongDetail(contentID)
|
||||
const { convertItemToSong } = await import('./util.ts')
|
||||
const song = convertItemToSong(item, true)
|
||||
if (!song) throw new Error('该歌曲没有可用格式')
|
||||
return song
|
||||
}
|
||||
|
||||
export default migu
|
||||
@@ -0,0 +1,42 @@
|
||||
// 咪咕歌词 —— resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本
|
||||
// 上游对应:music-lib/migu/lyric.go
|
||||
import { buildUrl, fetchSongDetail, miguGet, UserAgent } from './util.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
|
||||
const LRC_REFERER = 'https://y.migu.cn/'
|
||||
const LRC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
|
||||
|
||||
/** 从 songmid(contentId|resourceType|formatType)或 extra 取 contentId */
|
||||
export const contentIdOf = (songInfo: any): string => {
|
||||
const extra = songInfo?.extra ?? {}
|
||||
if (extra.content_id) return String(extra.content_id)
|
||||
const id = String(songInfo?.songmid ?? songInfo?.id ?? '')
|
||||
return id.split('|')[0] ?? ''
|
||||
}
|
||||
|
||||
export const getLyric = (songInfo: any) => {
|
||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||
const contentID = contentIdOf(songInfo)
|
||||
requestObj.promise = (async() => {
|
||||
if (!contentID) throw new Error('migu 歌曲 id 无效')
|
||||
const body = await miguGet(buildUrl('http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do', {
|
||||
resourceId: contentID,
|
||||
resourceType: '2',
|
||||
}))
|
||||
const resource = body?.resource ?? []
|
||||
if (!resource.length) throw new Error('migu 歌词资源不存在')
|
||||
let lrcUrl = String(resource[0]?.lrcUrl ?? '') || String(resource[0]?.lyricUrl ?? '')
|
||||
if (!lrcUrl) return { lyric: '', tlyric: '' }
|
||||
lrcUrl = lrcUrl.replace(/^http:\/\//, 'https://')
|
||||
const { statusCode, body: lrc } = await httpFetch(lrcUrl, {
|
||||
headers: { 'User-Agent': LRC_UA, Referer: LRC_REFERER },
|
||||
timeout: 20000,
|
||||
}).promise
|
||||
if (statusCode !== 200) throw new Error(`migu 歌词下载失败 ${statusCode}`)
|
||||
return { lyric: typeof lrc === 'string' ? lrc : '', tlyric: '' }
|
||||
})()
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export { fetchSongDetail }
|
||||
export default { getLyric }
|
||||
@@ -0,0 +1,44 @@
|
||||
// 咪咕音乐搜索 —— search_all.do(Android_migu 参数),searchSwitch 控制返回类别
|
||||
// 上游对应:music-lib/migu/migu.go 的 (*Migu).Search
|
||||
import { buildUrl, convertItemToSong, miguGet, type MiguSongItem } from './util.ts'
|
||||
|
||||
export const SEARCH_ALL_URL = 'http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do'
|
||||
|
||||
/** 搜索开关:只开某一类(song/album/songlist),bestShow 保留最优推荐 */
|
||||
export const searchSwitch = (kind: 'song' | 'album' | 'songlist'): string => JSON.stringify({
|
||||
song: kind === 'song' ? 1 : 0,
|
||||
album: kind === 'album' ? 1 : 0,
|
||||
singer: 0,
|
||||
tagSong: 0,
|
||||
mvSong: 0,
|
||||
songlist: kind === 'songlist' ? 1 : 0,
|
||||
bestShow: 1,
|
||||
})
|
||||
|
||||
export const searchAll = async(text: string, kind: 'song' | 'album' | 'songlist', page = 1, limit = 20): Promise<any> => {
|
||||
const url = buildUrl(SEARCH_ALL_URL, {
|
||||
ua: 'Android_migu',
|
||||
version: '5.0.1',
|
||||
text,
|
||||
pageNo: page,
|
||||
pageSize: limit,
|
||||
searchSwitch: searchSwitch(kind),
|
||||
})
|
||||
return miguGet(url)
|
||||
}
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const body = await searchAll(keyword, 'song', page, limit)
|
||||
const result: MiguSongItem[] = body?.songResultData?.result ?? []
|
||||
const list = result.map(item => convertItemToSong(item, true)).filter((s): s is NonNullable<typeof s> => s != null)
|
||||
const total = parseInt(String(body?.songResultData?.totalCount ?? list.length), 10) || list.length
|
||||
return {
|
||||
list,
|
||||
allPage: Math.max(1, Math.ceil(total / limit)),
|
||||
total,
|
||||
limit,
|
||||
source: 'migu' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,153 @@
|
||||
// 咪咕歌单 —— 搜索(search_all.do songlist 开关)、曲目(MIGUM3.0 playlist/song/v2.0)、
|
||||
// 分类(静态关键词表,上游同款,平台未提供分类接口)、链接解析
|
||||
// 上游对应:music-lib/migu/playlist.go
|
||||
import {
|
||||
buildUrl, convertItemToSong, firstNonEmpty, miguGet, miguPlaylistLink,
|
||||
pickMiguImage, type MiguImageItem, type MiguSong, type MiguSongItem,
|
||||
} from './util.ts'
|
||||
import { searchAll } from './musicSearch.ts'
|
||||
|
||||
interface MiguPlaylistItem {
|
||||
id?: string
|
||||
name?: string
|
||||
musicNum?: string
|
||||
userName?: string
|
||||
ownerName?: string
|
||||
musicListPicUrl?: string
|
||||
playNum?: string
|
||||
resourceType?: string
|
||||
imgItems?: MiguImageItem[]
|
||||
}
|
||||
|
||||
const toPlaylist = (item: MiguPlaylistItem, categoryId?: string) => {
|
||||
const playlistID = String(item.id ?? '').trim()
|
||||
const name = String(item.name ?? '').trim()
|
||||
if (!playlistID || !name) return null
|
||||
return {
|
||||
id: playlistID,
|
||||
name,
|
||||
cover: firstNonEmpty(item.musicListPicUrl, pickMiguImage(item.imgItems)),
|
||||
trackCount: parseInt(String(item.musicNum ?? ''), 10) || 0,
|
||||
playCount: parseInt(String(item.playNum ?? ''), 10) || 0,
|
||||
creator: firstNonEmpty(item.userName, item.ownerName),
|
||||
link: miguPlaylistLink(playlistID),
|
||||
source: 'migu' as const,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 searchPlaylists);分类歌单也用同一接口,关键词即分类名 */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const body = await searchAll(keyword, 'songlist', page, limit)
|
||||
const result: MiguPlaylistItem[] = body?.songListResultData?.result ?? []
|
||||
const list = result.map(item => toPlaylist(item)).filter(Boolean)
|
||||
return { list, source: 'migu' as const, page, limit, total: list.length }
|
||||
}
|
||||
|
||||
/** 静态分类(上游 miguPlaylistCategoryKeywords) */
|
||||
const CATEGORY_KEYWORDS = [
|
||||
{ name: '华语', group: '语种', hot: true },
|
||||
{ name: '欧美', group: '语种', hot: true },
|
||||
{ name: '日语', group: '语种' },
|
||||
{ name: '韩语', group: '语种' },
|
||||
{ name: '粤语', group: '语种' },
|
||||
{ name: '流行', group: '风格', hot: true },
|
||||
{ name: '摇滚', group: '风格', hot: true },
|
||||
{ name: '民谣', group: '风格', hot: true },
|
||||
{ name: '电子', group: '风格' },
|
||||
{ name: '说唱', group: '风格' },
|
||||
{ name: '古风', group: '风格' },
|
||||
{ name: '轻音乐', group: '风格' },
|
||||
{ name: '影视', group: '场景', hot: true },
|
||||
{ name: 'ACG', group: '场景' },
|
||||
{ name: '治愈', group: '场景' },
|
||||
{ name: '运动', group: '场景' },
|
||||
{ name: '学习', group: '场景' },
|
||||
{ name: '睡前', group: '场景' },
|
||||
]
|
||||
|
||||
export const getCategories = () => ({
|
||||
source: 'migu' as const,
|
||||
list: [
|
||||
{ id: '', name: '全部', group: '全部' },
|
||||
...CATEGORY_KEYWORDS.map(c => ({ id: c.name, name: c.name, group: c.group, hot: Boolean(c.hot) })),
|
||||
],
|
||||
})
|
||||
|
||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||
const keyword = String(categoryId ?? '').trim() || '华语'
|
||||
const { list } = await searchPlaylist(keyword, page, limit)
|
||||
return { list: list.map(p => toPlaylist(p as any, keyword)).filter(Boolean), source: 'migu' as const, page, limit }
|
||||
}
|
||||
|
||||
/** 歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs) */
|
||||
export const fetchPlaylistSongs = async(playlistId: string): Promise<{ songs: MiguSong[], total: number }> => {
|
||||
const playlistID = String(playlistId ?? '').trim()
|
||||
if (!playlistID) throw new Error('migu 歌单 id 为空')
|
||||
const pageSize = 50
|
||||
const seen = new Set<string>()
|
||||
const songs: MiguSong[] = []
|
||||
let totalCount = 0
|
||||
|
||||
for (let pageNo = 1; ; pageNo++) {
|
||||
const body = await miguGet(buildUrl('https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0', {
|
||||
pageNo, pageSize, playlistId: playlistID,
|
||||
}))
|
||||
if (body?.code && body.code !== '000000') throw new Error(`migu api error: ${body.info} (code ${body.code})`)
|
||||
const songList: MiguSongItem[] = body?.data?.songList ?? []
|
||||
if (!totalCount) totalCount = Number(body?.data?.totalCount ?? 0) || 0
|
||||
if (!songList.length) break
|
||||
|
||||
const before = songs.length
|
||||
for (const item of songList) {
|
||||
const song = convertItemToSong(item, true)
|
||||
if (!song) continue
|
||||
const key = firstNonEmpty(song.extra.content_id, item.contentId, song.songmid, item.copyrightId)
|
||||
|| `${song.name}|${song.singer}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
songs.push(song)
|
||||
}
|
||||
|
||||
if (songList.length < pageSize) break
|
||||
if (totalCount > 0 && songs.length >= totalCount) break
|
||||
if (songs.length === before) break
|
||||
}
|
||||
|
||||
if (!songs.length) throw new Error('migu 歌单没有可播放歌曲')
|
||||
return { songs, total: totalCount || songs.length }
|
||||
}
|
||||
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const playlistID = String(id ?? '').trim()
|
||||
const { songs, total } = await fetchPlaylistSongs(playlistID)
|
||||
const start = (page - 1) * limit
|
||||
return {
|
||||
source: 'migu' as const,
|
||||
info: { name: playlistID, img: songs[0]?.img ?? '', desc: '', author: '' },
|
||||
list: songs.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: total || songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
export const getPlaylistSongs = async(id: string) => (await fetchPlaylistSongs(id)).songs
|
||||
|
||||
const PLAYLIST_PATTERNS = [
|
||||
/playlistId=(\d+)/,
|
||||
/musicListId=(\d+)/,
|
||||
/(?:playlist|songlist)\/(\d+)/,
|
||||
]
|
||||
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link ?? '')
|
||||
for (const pattern of PLAYLIST_PATTERNS) {
|
||||
const m = raw.match(pattern)
|
||||
if (m) return getPlaylistDetail(m[1]!)
|
||||
}
|
||||
if (raw && !raw.includes('/')) return getPlaylistDetail(raw)
|
||||
throw new Error('无效的咪咕歌单链接')
|
||||
}
|
||||
|
||||
export default { searchPlaylist, getPlaylistDetail, getPlaylistSongs, getCategories, getCategoryPlaylists, parsePlaylistLink }
|
||||
@@ -0,0 +1,433 @@
|
||||
// 咪咕音乐(migu)公共层 —— 常量、请求、条目 → 歌曲的统一转换、档位与解密工具
|
||||
// 上游对应:music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等)
|
||||
// 说明:匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie(对话框「登录后免费试听」)。
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
import type { SongType } from '../types.ts'
|
||||
|
||||
/** 咪咕 toneFlag → 平台音质标签(types 展示与取流返回共用) */
|
||||
export const TONE_QUALITY: Record<string, string> = {
|
||||
LQ: '64k',
|
||||
PQ: '128k',
|
||||
HQ: '320k',
|
||||
SQ: 'flac',
|
||||
ZQ: 'flac24bit',
|
||||
ZQ24: 'flac24bit',
|
||||
ZQ32: 'wav',
|
||||
Z3D: 'wav',
|
||||
I3D: 'flac24bit',
|
||||
'3D60': 'wav',
|
||||
}
|
||||
|
||||
export const UserAgent = 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
|
||||
export const Referer = 'http://music.migu.cn/'
|
||||
|
||||
/** listen-url 走 Android 客户端 UA/头,与网页通道不同 UA */
|
||||
export const AndroidUA = 'Android_migu/7.41.13 okhttp/3.12.13'
|
||||
export const AndroidChannel = '0146832'
|
||||
export const AndroidVersion = '7.41.13'
|
||||
|
||||
/** Z3D 加密流的 app code 密文(AES-128-CBC,key 为 AppKey 前 16 字符) */
|
||||
const ANDROID_APP_KEY = '308202333082019c'
|
||||
const ENCRYPTED_APP_CODE = Buffer.from([
|
||||
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
|
||||
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
|
||||
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
|
||||
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
|
||||
])
|
||||
|
||||
export const getMiguCookie = (): string => {
|
||||
const cred = getCredentials('migu') as { cookie?: string } | null
|
||||
return String(cred?.cookie ?? '').trim()
|
||||
}
|
||||
|
||||
/** 带 Referer/UA/Cookie 的 GET,返回解析后的 body(JSON 已解析) */
|
||||
export const miguGet = async(url: string, extraHeaders: Record<string, string> = {}): Promise<any> => {
|
||||
const cookie = getMiguCookie()
|
||||
const headers: Record<string, string> = { 'User-Agent': UserAgent, Referer, ...extraHeaders }
|
||||
if (cookie) headers.Cookie = cookie
|
||||
const { statusCode, body } = await httpFetch(url, { headers, timeout: 20000 }).promise
|
||||
if (statusCode !== 200) throw new Error(`migu http ${statusCode}`)
|
||||
return body
|
||||
}
|
||||
|
||||
export const buildUrl = (base: string, params: Record<string, string | number>): string => {
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) qs.set(k, String(v))
|
||||
return `${base}?${qs.toString()}`
|
||||
}
|
||||
|
||||
// ---------------- 数据形状(对齐上游 MiguSongItem 结构体) ----------------
|
||||
|
||||
export interface MiguImageItem { imgSizeType?: string, img?: string }
|
||||
interface MiguArtistItem { id?: string, name?: string }
|
||||
interface MiguAlbumItem { id?: string, name?: string }
|
||||
export interface MiguRateFormat {
|
||||
formatType?: string
|
||||
resourceType?: string
|
||||
size?: string
|
||||
androidSize?: string
|
||||
isize?: string
|
||||
asize?: string
|
||||
fileType?: string
|
||||
androidFileType?: string
|
||||
iformat?: string
|
||||
aformat?: string
|
||||
price?: string
|
||||
showTag?: string[]
|
||||
showTags?: string[]
|
||||
}
|
||||
export interface MiguZ3DCode {
|
||||
formatType?: string
|
||||
resourceType?: string
|
||||
size?: string
|
||||
androidSize?: string
|
||||
isize?: string
|
||||
asize?: string
|
||||
fileType?: string
|
||||
androidFileType?: string
|
||||
iformat?: string
|
||||
aformat?: string
|
||||
price?: string
|
||||
z3dKey?: string
|
||||
z3dIv?: string
|
||||
androidFileKey?: string
|
||||
iosFileKey?: string
|
||||
iosSize?: string
|
||||
iosFileType?: string
|
||||
url?: string
|
||||
iosUrl?: string
|
||||
androidUrl?: string
|
||||
h5Url?: string
|
||||
}
|
||||
export interface MiguSongItem {
|
||||
id?: string
|
||||
name?: string
|
||||
songName?: string
|
||||
songId?: string
|
||||
singers?: MiguArtistItem[]
|
||||
artists?: MiguArtistItem[]
|
||||
singerList?: MiguArtistItem[]
|
||||
albums?: MiguAlbumItem[]
|
||||
albumId?: string
|
||||
album?: string
|
||||
singer?: string
|
||||
contentId?: string
|
||||
copyrightId?: string
|
||||
chargeAuditions?: string
|
||||
downloadTags?: string[]
|
||||
imgItems?: MiguImageItem[]
|
||||
albumImgs?: MiguImageItem[]
|
||||
newRateFormats?: MiguRateFormat[]
|
||||
rateFormats?: MiguRateFormat[]
|
||||
audioFormats?: MiguRateFormat[]
|
||||
z3dCode?: MiguZ3DCode | null
|
||||
img1?: string
|
||||
img2?: string
|
||||
img3?: string
|
||||
duration?: number
|
||||
vipType?: string
|
||||
}
|
||||
|
||||
// ---------------- 工具函数(1:1 对应上游) ----------------
|
||||
|
||||
export const firstNonEmpty = (...values: Array<string | undefined | null>): string => {
|
||||
for (const value of values) {
|
||||
if (value != null && String(value).trim() !== '') return String(value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const firstNonZeroString = (...values: Array<string | undefined | null>): string => {
|
||||
for (const value of values) {
|
||||
const s = String(value ?? '').trim()
|
||||
if (s !== '' && s !== '0') return s
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const pickMiguImage = (items?: MiguImageItem[] | null): string => {
|
||||
if (!Array.isArray(items)) return ''
|
||||
for (const preferred of ['02', '01', '03']) {
|
||||
for (const item of items) {
|
||||
if (item?.imgSizeType === preferred && String(item?.img ?? '').trim() !== '') return String(item.img)
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
if (String(item?.img ?? '').trim() !== '') return String(item.img)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const normalizeMiguImageURL = (image?: string | null): string => {
|
||||
const url = String(image ?? '').trim()
|
||||
if (!url) return ''
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) return url
|
||||
if (url.startsWith('//')) return `https:${url}`
|
||||
if (url.startsWith('/')) return `https://d.musicapp.migu.cn${url}`
|
||||
return url
|
||||
}
|
||||
|
||||
export const collectMiguArtistNames = (item: MiguSongItem): string[] => {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const append = (name?: string) => {
|
||||
const n = String(name ?? '').trim()
|
||||
if (!n || seen.has(n)) return
|
||||
seen.add(n)
|
||||
names.push(n)
|
||||
}
|
||||
for (const s of item.singers ?? []) append(s?.name)
|
||||
for (const s of item.singerList ?? []) append(s?.name)
|
||||
for (const a of item.artists ?? []) append(a?.name)
|
||||
if (!names.length) for (const n of String(item.singer ?? '').split('|')) append(n)
|
||||
return names
|
||||
}
|
||||
|
||||
/** 汇总三组 rateFormats + z3dCode,按 formatType|resourceType 去重(上游 collectMiguFormats) */
|
||||
export const collectMiguFormats = (item: MiguSongItem): MiguRateFormat[] => {
|
||||
const groups: MiguRateFormat[][] = [
|
||||
item.newRateFormats ?? [],
|
||||
item.rateFormats ?? [],
|
||||
item.audioFormats ?? [],
|
||||
]
|
||||
const code = item.z3dCode
|
||||
if (code && code.formatType) {
|
||||
groups.push([{
|
||||
formatType: code.formatType,
|
||||
resourceType: code.resourceType,
|
||||
size: code.size,
|
||||
androidSize: code.androidSize,
|
||||
isize: code.isize,
|
||||
asize: code.asize,
|
||||
fileType: code.fileType,
|
||||
androidFileType: code.androidFileType,
|
||||
iformat: code.iformat,
|
||||
aformat: code.aformat,
|
||||
price: code.price,
|
||||
}])
|
||||
}
|
||||
const formats: MiguRateFormat[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const group of groups) {
|
||||
for (const format of group) {
|
||||
const key = `${String(format.formatType ?? '').trim().toUpperCase()}|${String(format.resourceType ?? '').trim()}`
|
||||
if (key === '|') continue
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
formats.push(format)
|
||||
}
|
||||
}
|
||||
return formats
|
||||
}
|
||||
|
||||
export const miguFormatRank = (formatType?: string): number => {
|
||||
switch (String(formatType ?? '').trim().toUpperCase()) {
|
||||
case 'ZQ32': return 80
|
||||
case 'ZQ': case 'ZQ24': return 75
|
||||
case 'SQ': return 70
|
||||
case 'HQ': return 60
|
||||
case 'I3D': case 'Z3D': return 50
|
||||
case '3D60': return 40
|
||||
case 'PQ': return 30
|
||||
case 'LQ': return 20
|
||||
default: return 10
|
||||
}
|
||||
}
|
||||
|
||||
export const miguFormatExt = (formatType?: string, formatCode?: string): string => {
|
||||
const type = String(formatType ?? '').trim().toUpperCase()
|
||||
const code = String(formatCode ?? '').trim()
|
||||
switch (type) {
|
||||
case 'SQ': case 'ZQ': case 'ZQ24': return 'flac'
|
||||
case 'ZQ32': case 'Z3D': case '3D60': return 'wav'
|
||||
case 'I3D': return 'm4a'
|
||||
case 'PQ': case 'HQ': case 'LQ': return 'mp3'
|
||||
}
|
||||
if (code.startsWith('011')) return 'flac'
|
||||
return 'mp3'
|
||||
}
|
||||
|
||||
export const miguAlbumLink = (id: string): string => `https://music.migu.cn/v3/music/album/${id}`
|
||||
export const miguPlaylistLink = (id: string): string => `https://music.migu.cn/v5/#/playlist?playlistId=${id}&playlistType=ordinary`
|
||||
export const miguSongLink = (id: string): string => `https://music.migu.cn/v3/music/song/${id}`
|
||||
|
||||
// ---------------- 条目 → 歌曲(上游 convertItemToSongWithOption) ----------------
|
||||
|
||||
export interface MiguSong {
|
||||
songmid: string
|
||||
name: string
|
||||
singer: string
|
||||
albumId: string
|
||||
albumName: string
|
||||
interval: string | number
|
||||
img: string | null
|
||||
lrc: string | null
|
||||
types: SongType[]
|
||||
_types: Record<string, { size: string }>
|
||||
payPlay?: boolean
|
||||
/** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */
|
||||
extra: Record<string, string>
|
||||
source: 'migu'
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** 把 "mm:ss"/秒数统一成上游 interval 形态(秒数)——路由层按原样透传 */
|
||||
const SECONDS_FROM_SIZE = (size: number, bitrate: number): number => (bitrate > 0 ? Math.floor(size * 8 / bitrate) : 0)
|
||||
|
||||
export const convertItemToSong = (item: MiguSongItem, allowPaid = true): MiguSong | null => {
|
||||
const artistNames = collectMiguArtistNames(item)
|
||||
const songName = firstNonEmpty(item.name, item.songName)
|
||||
let albumName = String(item.album ?? '').trim()
|
||||
if (item.albums?.length && String(item.albums[0]?.name ?? '').trim() !== '') {
|
||||
albumName = String(item.albums[0]!.name).trim()
|
||||
}
|
||||
|
||||
const rateFormats = collectMiguFormats(item)
|
||||
if (!rateFormats.length) return null
|
||||
|
||||
let duration = Number(item.duration ?? 0) || 0
|
||||
const candidates: Array<{ index: number, size: number, ext: string, rank: number, vip: boolean, tone: string }> = []
|
||||
|
||||
rateFormats.forEach((fmtItem, i) => {
|
||||
const sizeStr = firstNonZeroString(fmtItem.androidSize, fmtItem.asize, fmtItem.size, fmtItem.isize)
|
||||
const sizeVal = parseInt(sizeStr, 10) || 0
|
||||
let ext = firstNonEmpty(fmtItem.androidFileType, fmtItem.fileType)
|
||||
if (!ext) ext = miguFormatExt(fmtItem.formatType, firstNonEmpty(fmtItem.aformat, fmtItem.iformat))
|
||||
|
||||
if (!duration && sizeVal > 0) {
|
||||
let bitrate = 0
|
||||
switch (fmtItem.formatType) {
|
||||
case 'PQ': bitrate = 128000; break
|
||||
case 'HQ': bitrate = 320000; break
|
||||
case 'LQ': bitrate = 64000; break
|
||||
}
|
||||
if (bitrate > 0) duration = SECONDS_FROM_SIZE(sizeVal, bitrate)
|
||||
}
|
||||
|
||||
const priceVal = parseInt(String(fmtItem.price ?? ''), 10) || 0
|
||||
const tags = fmtItem.showTag?.length ? fmtItem.showTag : (fmtItem.showTags ?? [])
|
||||
let isVipTag = tags.some(tag => String(tag ?? '').trim().toLowerCase() === 'vip')
|
||||
if (!isVipTag) {
|
||||
isVipTag = (item.downloadTags ?? []).some(tag => String(tag ?? '').trim().toLowerCase() === 'vip')
|
||||
}
|
||||
const isHiddenPaid = item.chargeAuditions === '1' && priceVal >= 200
|
||||
|
||||
if (allowPaid || (!isVipTag && !isHiddenPaid)) {
|
||||
candidates.push({
|
||||
index: i, size: sizeVal, ext, rank: miguFormatRank(fmtItem.formatType),
|
||||
vip: isVipTag || isHiddenPaid, tone: String(fmtItem.formatType ?? ''),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (!candidates.length) return null
|
||||
|
||||
candidates.sort((a, b) => (a.rank !== b.rank ? b.rank - a.rank : b.size - a.size))
|
||||
const best = candidates[0]!
|
||||
const bestFormat = rateFormats[best.index]!
|
||||
|
||||
const bitrate = duration > 0 && best.size > 0 ? Math.floor(best.size * 8 / 1000 / duration) : 0
|
||||
const coverURL = normalizeMiguImageURL(firstNonEmpty(
|
||||
pickMiguImage(item.imgItems), pickMiguImage(item.albumImgs), item.img1, item.img2, item.img3,
|
||||
))
|
||||
// albums 数组是搜索响应的专辑来源(顶层 albumId 常为空),与上游 Extra 的 album_id 同义
|
||||
const albumId = firstNonEmpty(item.albumId, item.albums?.[0]?.id)
|
||||
const linkID = firstNonEmpty(item.contentId, item.copyrightId)
|
||||
|
||||
const extra: Record<string, string> = {
|
||||
content_id: String(item.contentId ?? ''),
|
||||
resource_type: String(bestFormat.resourceType ?? ''),
|
||||
format_type: String(bestFormat.formatType ?? ''),
|
||||
}
|
||||
if (item.copyrightId) extra.copyright_id = item.copyrightId
|
||||
if (item.songId) extra.song_id = item.songId
|
||||
if (albumId) extra.album_id = albumId
|
||||
|
||||
const isVIP = item.chargeAuditions === '1' || best.vip
|
||||
const resourceType = String(bestFormat.resourceType ?? '2')
|
||||
const id = `${item.contentId ?? ''}|${resourceType}|${String(bestFormat.formatType ?? '')}`
|
||||
|
||||
// 各档位可用性(供前端展示);实际可取档位由取流接口的探测结果决定
|
||||
const types: SongType[] = candidates.map(c => ({
|
||||
type: (TONE_QUALITY[c.tone] ?? '128k') as SongType['type'],
|
||||
size: String(c.size),
|
||||
}))
|
||||
|
||||
return {
|
||||
songmid: id,
|
||||
name: songName,
|
||||
singer: artistNames.join(' / '),
|
||||
albumId,
|
||||
albumName,
|
||||
interval: duration ? formatSeconds(duration) : '0',
|
||||
img: coverURL || null,
|
||||
lrc: null,
|
||||
types,
|
||||
_types: {},
|
||||
payPlay: isVIP,
|
||||
// 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里
|
||||
extra: { ...extra, link: miguSongLink(linkID) },
|
||||
source: 'migu',
|
||||
}
|
||||
}
|
||||
|
||||
export const formatSeconds = (sec: number): string => {
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// ---------------- 歌曲详情 / 歌词 ----------------
|
||||
|
||||
export const fetchSongDetail = async(contentID: string): Promise<MiguSongItem> => {
|
||||
const url = buildUrl('http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do', {
|
||||
resourceType: '2',
|
||||
resourceId: contentID,
|
||||
})
|
||||
const body = await miguGet(url)
|
||||
const resource: MiguSongItem[] = body?.resource ?? []
|
||||
if (!resource.length) throw new Error('migu 歌曲详情为空')
|
||||
return resource[0]!
|
||||
}
|
||||
|
||||
// ---------------- Z3D 解密(上游 DecryptAudio / miguZ3DStreamKey) ----------------
|
||||
|
||||
export const isMiguAudioMagic = (data: Buffer): boolean => {
|
||||
if (!data || data.length < 4) return false
|
||||
const h = data.subarray(0, 4)
|
||||
return h.toString('latin1') === 'fLaC'
|
||||
|| (h.toString('latin1') === 'RIFF' && data.length >= 12 && data.subarray(8, 12).toString('latin1') === 'WAVE')
|
||||
|| h.toString('latin1') === 'OggS'
|
||||
|| data.subarray(0, 3).toString('latin1') === 'ID3'
|
||||
|| (data.length >= 12 && data.subarray(4, 8).toString('latin1') === 'ftyp')
|
||||
|| (data[0] === 0xFF && (data[1]! & 0xE0) === 0xE0)
|
||||
}
|
||||
|
||||
/** streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制 */
|
||||
export const miguZ3DStreamKey = (fileKey: string): Buffer => {
|
||||
const key = String(fileKey ?? '').trim()
|
||||
if (!key) throw new Error('empty z3d file key')
|
||||
const iv = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15])
|
||||
const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(ANDROID_APP_KEY), iv)
|
||||
// Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,Node 默认会去填充导致 bad decrypt
|
||||
decipher.setAutoPadding(false)
|
||||
const appCode = Buffer.concat([decipher.update(ENCRYPTED_APP_CODE), decipher.final()])
|
||||
const hash = crypto.createHash('md5').update(appCode).update(key).digest('hex').toUpperCase()
|
||||
return Buffer.from(hash, 'latin1')
|
||||
}
|
||||
|
||||
/** Z3D 流解密:逐字节 cipher[i] - streamKey[i % 32];已是明文则原样返回 */
|
||||
export const decryptZ3D = (fileData: Buffer, fileKey: string): Buffer => {
|
||||
if (!fileData?.length) throw new Error('empty z3d data')
|
||||
if (isMiguAudioMagic(fileData)) return fileData
|
||||
let payload = fileData
|
||||
if (payload.subarray(0, 3).toString('latin1') === 'Z3D') payload = payload.subarray(3)
|
||||
if (!payload.length) throw new Error('empty z3d payload')
|
||||
const streamKey = miguZ3DStreamKey(fileKey)
|
||||
const plain = Buffer.alloc(payload.length)
|
||||
for (let i = 0; i < payload.length; i++) plain[i] = payload[i]! - streamKey[i & 31]!
|
||||
if (!isMiguAudioMagic(plain)) throw new Error('z3d 解密后不是有效音频')
|
||||
return plain
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
// 千千音乐专辑
|
||||
// 搜索:/v1/search?type=3(特殊字符触发 errno 23001 时用清洗后的关键词重试,见 searchAlbumItems)
|
||||
// 详情:/v1/album/info?albumAssetCode=P...(P 码可直接用;纯数字旧 id 走 /v1/album/albumid2psid 转换)
|
||||
// 专辑链接:https://music.91q.com/album/<albumAssetCode>
|
||||
import { APP_ID, apiGet, apiOk, joinArtists, normalizeAlbumAssetCode, mapTrack, releaseDate10, sanitizeAlbumKeyword, albumLink } from './util.ts'
|
||||
|
||||
export interface QianAlbumSearchItem {
|
||||
albumAssetCode?: string
|
||||
title?: string
|
||||
pic?: string
|
||||
introduce?: string
|
||||
releaseDate?: string
|
||||
genre?: string
|
||||
lang?: string
|
||||
artist?: any[]
|
||||
trackList?: any[]
|
||||
}
|
||||
|
||||
const mapAlbumSummary = (item: QianAlbumSearchItem) => {
|
||||
const id = normalizeAlbumAssetCode(item?.albumAssetCode)
|
||||
return {
|
||||
id,
|
||||
name: String(item?.title ?? '').trim(),
|
||||
cover: String(item?.pic ?? '').trim(),
|
||||
trackCount: Array.isArray(item?.trackList) ? item.trackList.length : 0,
|
||||
creator: joinArtists(item?.artist),
|
||||
description: String(item?.introduce ?? '').trim(),
|
||||
releaseDate: releaseDate10(item?.releaseDate),
|
||||
genre: String(item?.genre ?? '').trim(),
|
||||
lang: String(item?.lang ?? '').trim(),
|
||||
link: id ? albumLink(id) : '',
|
||||
source: 'qian' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 单次专辑搜索;23001(词非法)时抛出带 retryWithSanitized 标记的错误 */
|
||||
const searchAlbumOnce = async(word: string, page = 1, limit = 10) => {
|
||||
const body = await apiGet('/v1/search', {
|
||||
word,
|
||||
type: '3',
|
||||
pageNo: String(Math.max(1, page)),
|
||||
pageSize: String(Math.max(1, limit)),
|
||||
appid: APP_ID,
|
||||
})
|
||||
if (!apiOk(body)) {
|
||||
const err: any = new Error(body?.errmsg || `千千专辑搜索失败(errno ${body?.errno})`)
|
||||
err.retryWithSanitized = Number(body?.errno) === 23001
|
||||
throw err
|
||||
}
|
||||
const list = body?.data?.typeAlbum
|
||||
return Array.isArray(list) ? (list as QianAlbumSearchItem[]) : []
|
||||
}
|
||||
|
||||
/** 上游 searchAlbumItems:原词失败(23001)时用 sanitizeQianqianAlbumKeyword 清洗后重试一次 */
|
||||
export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Promise<QianAlbumSearchItem[]> => {
|
||||
const words = [String(keyword ?? '').trim()]
|
||||
const sanitized = sanitizeAlbumKeyword(keyword)
|
||||
if (sanitized && sanitized !== words[0]) words.push(sanitized)
|
||||
|
||||
let lastErr: Error | null = null
|
||||
for (const word of words) {
|
||||
try {
|
||||
return await searchAlbumOnce(word, page, limit)
|
||||
} catch (err: any) {
|
||||
lastErr = err
|
||||
// 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛
|
||||
if (!err?.retryWithSanitized) break
|
||||
}
|
||||
}
|
||||
if (lastErr) throw lastErr
|
||||
return []
|
||||
}
|
||||
|
||||
/** 搜索专辑(返回歌单/专辑统一摘要形状) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
|
||||
if (!word) return empty
|
||||
|
||||
const items = await searchAlbumItems(word, page, limit)
|
||||
const list = items
|
||||
.map(mapAlbumSummary)
|
||||
.filter((album) => album.id)
|
||||
.slice(0, limit)
|
||||
return { source: 'qian' as const, list, total: items.length, page, limit }
|
||||
}
|
||||
|
||||
/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */
|
||||
export const resolveAlbumAssetCode = async(id: string): Promise<string> => {
|
||||
const raw = String(id ?? '').trim()
|
||||
if (!raw) throw new Error('千千专辑 id 为空')
|
||||
|
||||
const normalized = normalizeAlbumAssetCode(raw)
|
||||
if (normalized) return normalized
|
||||
|
||||
const body = await apiGet('/v1/album/albumid2psid', { albumid: raw, appid: APP_ID })
|
||||
if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑 id 转换失败(errno ${body?.errno})`)
|
||||
for (const item of Array.isArray(body?.data) ? body.data : []) {
|
||||
const psid = normalizeAlbumAssetCode(item?.psid)
|
||||
if (psid) return psid
|
||||
}
|
||||
throw new Error('千千专辑 asset code 未找到')
|
||||
}
|
||||
|
||||
const fetchAlbumInfo = async(id: string) => {
|
||||
const albumId = await resolveAlbumAssetCode(id)
|
||||
const body = await apiGet('/v1/album/info', { albumAssetCode: albumId, appid: APP_ID })
|
||||
if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑详情获取失败(errno ${body?.errno})`)
|
||||
const data = body?.data ?? {}
|
||||
if (!normalizeAlbumAssetCode(data?.albumAssetCode)) throw new Error('千千专辑不存在或已下架')
|
||||
return { albumId, data }
|
||||
}
|
||||
|
||||
/** 专辑详情(MusicServer 统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片) */
|
||||
export const getAlbumDetail = async(id: string, page = 1, limit = 30) => {
|
||||
const { albumId, data } = await fetchAlbumInfo(id)
|
||||
const title = String(data?.title ?? '').trim()
|
||||
const cover = String(data?.pic ?? '').trim()
|
||||
const creator = joinArtists(data?.artist)
|
||||
const all = Array.isArray(data?.trackList) ? data.trackList : []
|
||||
|
||||
const list = all
|
||||
.map((item: any) => {
|
||||
const song = mapTrack(item, { albumId, albumName: title, img: cover, singer: creator })
|
||||
return {
|
||||
...song,
|
||||
// 专辑内曲目补上时长/序号(上游 Extra.track / sort)
|
||||
track: Number(item?.sort) > 0 ? Number(item.sort) : undefined,
|
||||
}
|
||||
})
|
||||
.filter((song: any) => song.songmid)
|
||||
|
||||
const start = (Math.max(1, page) - 1) * Math.max(1, limit)
|
||||
const paged = list.slice(start, start + Math.max(1, limit))
|
||||
|
||||
const albumInfo = {
|
||||
id: albumId,
|
||||
name: title,
|
||||
img: cover,
|
||||
desc: String(data?.introduce ?? '').trim(),
|
||||
author: creator,
|
||||
releaseDate: releaseDate10(data?.releaseDate),
|
||||
genre: String(data?.genre ?? '').trim(),
|
||||
lang: String(data?.lang ?? '').trim(),
|
||||
total: list.length,
|
||||
}
|
||||
|
||||
return {
|
||||
source: 'qian' as const,
|
||||
info: albumInfo,
|
||||
list: paged,
|
||||
page,
|
||||
limit,
|
||||
total: list.length,
|
||||
allPage: Math.ceil(list.length / Math.max(1, limit)) || 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetAlbumSongs:只取曲目列表 */
|
||||
export const getAlbumSongs = async(id: string) => {
|
||||
const { list } = await getAlbumDetail(id, 1, 10000)
|
||||
return list
|
||||
}
|
||||
|
||||
/** 上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态 */
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const patterns = [
|
||||
/music\.91q\.com\/album\/([A-Za-z0-9]+)/,
|
||||
/albumAssetCode=([A-Za-z0-9]+)/,
|
||||
/albumid=(\d+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(raw)
|
||||
if (match) return getAlbumDetail(match[1])
|
||||
}
|
||||
throw new Error('无效的千千专辑链接')
|
||||
}
|
||||
|
||||
export default { searchAlbum, searchAlbumItems, getAlbumDetail, getAlbumSongs, parseAlbumLink, resolveAlbumAssetCode }
|
||||
@@ -0,0 +1,86 @@
|
||||
// 千千音乐(music.91q.com)平台模块
|
||||
// 上游 music-lib/qianqian 的 Node/TS 移植。匿名可用面(2026-10-02 实测):
|
||||
// 搜索(type=1)/ 专辑搜索+详情(type=3、album/info)/ 歌单详情+分类+分类歌单 /
|
||||
// 歌词(song/info 返回 txt URL)/ 免费曲目 128k/320k/flac 明文直链(tracklink 签名通道)。
|
||||
// 限制:
|
||||
// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay;
|
||||
// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表;
|
||||
// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。
|
||||
// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选)。
|
||||
import qianApi from '../apis/qian.ts'
|
||||
import musicSearch from './musicSearch.ts'
|
||||
import lyric from './lyric.ts'
|
||||
import { getSongInfo } from './musicInfo.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import { getQianCookie } from './util.ts'
|
||||
|
||||
const qian = {
|
||||
musicSearch,
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return qianApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
|
||||
getLyric(songInfo) {
|
||||
return lyric.getLyric(songInfo)
|
||||
},
|
||||
|
||||
// 千千无独立封面接口;搜索结果/专辑/歌单均自带 pic,缺失时回退 song/info
|
||||
async getPic(songInfo) {
|
||||
if (songInfo?.img) return songInfo.img
|
||||
try {
|
||||
const info = await getSongInfo(String(songInfo?.songmid ?? ''))
|
||||
return info?.img || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
getMusicInfo(songInfo) {
|
||||
return getSongInfo(String(songInfo?.songmid ?? ''))
|
||||
},
|
||||
|
||||
getMusicDetailPageUrl(songInfo) {
|
||||
const tsid = String(songInfo?.songmid ?? '').trim()
|
||||
return tsid ? `https://music.91q.com/song/${tsid}` : 'https://music.91q.com/'
|
||||
},
|
||||
|
||||
album: {
|
||||
getAlbumDetail: album.getAlbumDetail,
|
||||
searchAlbum: album.searchAlbum,
|
||||
parseAlbumLink: album.parseAlbumLink,
|
||||
},
|
||||
|
||||
// MusicServer 的 songList 契约:getList(sortId, tagId, page) / getTags()
|
||||
// 千千无“榜单”概念,这里以歌单分类作为标签体系(tagId 为 /v1/tracklist/category 的 subCate id)
|
||||
songList: {
|
||||
getList(sortId, tagId, page = 1) {
|
||||
const cate = String(tagId || sortId || '').trim()
|
||||
return playlist.getCategoryPlaylists(/^\d+$/.test(cate) ? cate : '', page, 30)
|
||||
},
|
||||
getTags() {
|
||||
return playlist.getCategories()
|
||||
},
|
||||
},
|
||||
|
||||
playlist: {
|
||||
searchPlaylist: playlist.searchPlaylist,
|
||||
getPlaylistDetail: playlist.getPlaylistDetail,
|
||||
getPlaylistSongs: playlist.getPlaylistSongs,
|
||||
getCategories: playlist.getCategories,
|
||||
getCategoryPlaylists: playlist.getCategoryPlaylists,
|
||||
parsePlaylistLink: playlist.parsePlaylistLink,
|
||||
},
|
||||
|
||||
parse,
|
||||
}
|
||||
|
||||
/** 凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流) */
|
||||
export const status = () => {
|
||||
const cookie = getQianCookie()
|
||||
return cookie ? { logged: true } : { logged: false }
|
||||
}
|
||||
|
||||
export default qian
|
||||
@@ -0,0 +1,34 @@
|
||||
// 千千音乐歌词
|
||||
// 链路:/v1/song/info → data[0].lyric 为歌词文本 URL(static-qianqian.taihe.com/*.txt)→ 直接下载文本。
|
||||
// 返回 { lyric, tlyric: '' }(千千只有原文歌词,无翻译)。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getSongInfo } from './musicInfo.ts'
|
||||
import { requestHeaders } from './util.ts'
|
||||
|
||||
export const getLyric = (songInfo: any) => {
|
||||
const requestObj: any = { promise: null, cancelHttp() {} }
|
||||
const tsid = String(songInfo?.songmid ?? songInfo?.TSID ?? '').trim()
|
||||
let lyricReq: any = null
|
||||
|
||||
requestObj.cancelHttp = () => {
|
||||
lyricReq?.cancelHttp?.()
|
||||
lyricReq = null
|
||||
}
|
||||
|
||||
requestObj.promise = (async() => {
|
||||
if (!tsid) throw new Error('千千歌词需要 songmid(TSID)')
|
||||
const info = await getSongInfo(tsid)
|
||||
const lyricUrl = info?.lyricUrl
|
||||
if (!lyricUrl) throw new Error('千千该歌曲暂无歌词')
|
||||
|
||||
lyricReq = httpFetch(lyricUrl, { headers: requestHeaders(), timeout: 15000 })
|
||||
const { statusCode, body, raw } = await lyricReq.promise as any
|
||||
if (statusCode != 200) throw new Error(`千千歌词下载失败(HTTP ${statusCode})`)
|
||||
const lyric = typeof body === 'string' ? body : raw?.toString('utf8') ?? ''
|
||||
return { lyric, tlyric: '' }
|
||||
})()
|
||||
|
||||
return requestObj
|
||||
}
|
||||
|
||||
export default { getLyric }
|
||||
@@ -0,0 +1,26 @@
|
||||
// 千千音乐单曲信息(/v1/song/info)
|
||||
// 接口返回 data[]:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL)
|
||||
import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
|
||||
|
||||
/** 由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl) */
|
||||
export const getSongInfo = async(tsid: string) => {
|
||||
const id = String(tsid ?? '').trim()
|
||||
if (!id) throw new Error('千千歌曲 id(TSID)为空')
|
||||
const body = await apiGet('/v1/song/info', { TSID: id, appid: APP_ID })
|
||||
if (!apiOk(body) && !Array.isArray(body?.data)) {
|
||||
throw new Error(body?.errmsg || `千千歌曲信息获取失败(errno ${body?.errno})`)
|
||||
}
|
||||
const item = Array.isArray(body?.data) ? body.data[0] : null
|
||||
if (!item) throw new Error('千千歌曲不存在或已下架')
|
||||
|
||||
const song = mapTrack(item)
|
||||
return {
|
||||
...song,
|
||||
// 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词)
|
||||
songmid: song.songmid || id,
|
||||
lyricUrl: String(item?.lyric ?? '').trim(),
|
||||
raw: item,
|
||||
}
|
||||
}
|
||||
|
||||
export default { getSongInfo }
|
||||
@@ -0,0 +1,35 @@
|
||||
// 千千音乐搜索(/v1/search?word=&type=1&pageNo=&pageSize=,appid + 签名)
|
||||
// 响应 data.typeTrack[]:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip
|
||||
// 注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay,
|
||||
// 以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。
|
||||
import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts'
|
||||
|
||||
export const search = async(keyword: string, page = 1, limit = 20) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'qian' as const }
|
||||
if (!word) return empty
|
||||
|
||||
const body = await apiGet('/v1/search', {
|
||||
word,
|
||||
type: '1',
|
||||
pageNo: String(page),
|
||||
pageSize: String(limit),
|
||||
appid: APP_ID,
|
||||
})
|
||||
if (!apiOk(body) && Number(body?.errno) !== 23001) {
|
||||
throw new Error(body?.errmsg || `千千搜索失败(errno ${body?.errno})`)
|
||||
}
|
||||
const tracks = Array.isArray(body?.data?.typeTrack) ? body.data.typeTrack : []
|
||||
const total = Number(body?.data?.total) || tracks.length
|
||||
const haveMore = Number(body?.data?.haveMore) === 1
|
||||
|
||||
return {
|
||||
list: tracks.map((item: any) => mapTrack(item)).filter((song: any) => song.songmid),
|
||||
allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1),
|
||||
total,
|
||||
limit,
|
||||
source: 'qian' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export default { search }
|
||||
@@ -0,0 +1,35 @@
|
||||
// 千千音乐链接解析
|
||||
// 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)
|
||||
// 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid=
|
||||
// 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*id= / 裸歌单 id
|
||||
import { getDownloadUrl } from '../apis/qian.ts'
|
||||
import { getSongInfo } from './musicInfo.ts'
|
||||
import { parseAlbumLink } from './album.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
import { firstNonEmpty } from './util.ts'
|
||||
|
||||
const TSID_RE = /music\.91q\.com\/song\/(\w+)/
|
||||
|
||||
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const match = TSID_RE.exec(String(link ?? ''))
|
||||
if (!match) throw new Error('无效的千千歌曲链接')
|
||||
const tsid = match[1]
|
||||
|
||||
const song: any = await getSongInfo(tsid)
|
||||
try {
|
||||
const hit = await getDownloadUrl(tsid)
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
} catch {
|
||||
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
}
|
||||
delete song.lyricUrl
|
||||
delete song.raw
|
||||
return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'qian' as const }
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,158 @@
|
||||
// 千千音乐歌单
|
||||
// 详情:/v1/tracklist/info?id=&type=0&pageNo=&pageSize=(支持分页,默认 pageSize=20)
|
||||
// 分类:/v1/tracklist/category(group.categoryName + subCate[])
|
||||
// 分类歌单:/v1/tracklist/list?subCateId=&pageNo=&pageSize=(不传 subCateId 为全部)
|
||||
// 搜索:上游用 /v1/search?type=6,但 2026-10 实测服务端已下线该搜索类型
|
||||
// (errno 23001 "不被支持的搜索类型(10000)"),官网搜索页也只有 单曲/歌手/专辑 三个 tab;
|
||||
// 上游在 !state 时静默返回空列表,本实现保持一致并保留 typeSonglist 解析以备恢复。
|
||||
import { APP_ID, apiGet, apiOk, firstNonEmpty, mapTrack, playlistLink } from './util.ts'
|
||||
|
||||
const mapPlaylistSummary = (item: any) => {
|
||||
const id = item?.id == null ? '' : String(item.id)
|
||||
const rawDesc = String(item?.desc ?? '').trim()
|
||||
const tagList = Array.isArray(item?.tagList) ? item.tagList : []
|
||||
return {
|
||||
id,
|
||||
name: String(item?.title ?? '').trim(),
|
||||
cover: String(item?.pic ?? '').trim(),
|
||||
trackCount: Number(item?.trackCount ?? 0) || 0,
|
||||
creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName),
|
||||
description: rawDesc || tagList.join('、'),
|
||||
link: id ? playlistLink(id) : '',
|
||||
source: 'qian' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
|
||||
const word = String(keyword ?? '').trim()
|
||||
const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit }
|
||||
if (!word) return empty
|
||||
|
||||
const body = await apiGet('/v1/search', {
|
||||
word,
|
||||
type: '6',
|
||||
pageNo: String(page),
|
||||
pageSize: String(limit),
|
||||
appid: APP_ID,
|
||||
})
|
||||
if (!body?.state) return empty
|
||||
|
||||
const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : []
|
||||
const list = items.map(mapPlaylistSummary).filter((item: any) => item.id)
|
||||
return { source: 'qian' as const, list, total: Number(body?.data?.total) || list.length, page, limit }
|
||||
}
|
||||
|
||||
/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const playlistId = String(id ?? '').trim()
|
||||
if (!playlistId) throw new Error('千千歌单 id 为空')
|
||||
|
||||
const body = await apiGet('/v1/tracklist/info', {
|
||||
id: playlistId,
|
||||
type: '0',
|
||||
pageNo: String(Math.max(1, page)),
|
||||
pageSize: String(Math.max(1, limit)),
|
||||
appid: APP_ID,
|
||||
})
|
||||
if (!apiOk(body)) throw new Error(body?.errmsg || `千千歌单获取失败(errno ${body?.errno})`)
|
||||
|
||||
const data = body?.data ?? {}
|
||||
const rawTracks = Array.isArray(data?.trackList) ? data.trackList : []
|
||||
const list = rawTracks.map((item: any) => mapTrack(item)).filter((song: any) => song.songmid)
|
||||
|
||||
const title = firstNonEmpty(data?.title, playlistId)
|
||||
const tagList = Array.isArray(data?.tagList) ? data.tagList : []
|
||||
const cover = firstNonEmpty(data?.pic, list[0]?.img)
|
||||
const creator = firstNonEmpty(data?.creator, data?.author, data?.userName, data?.nickName, data?.ownerName)
|
||||
const description = firstNonEmpty(data?.desc, data?.description) || tagList.join('、')
|
||||
const total = Number(data?.trackCount) || list.length
|
||||
|
||||
return {
|
||||
source: 'qian' as const,
|
||||
info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) },
|
||||
list,
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
allPage: Math.ceil(total / Math.max(1, limit)) || 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 GetPlaylistSongs:只取曲目列表 */
|
||||
export const getPlaylistSongs = async(id: string) => {
|
||||
const { list } = await getPlaylistDetail(id, 1, 1000)
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* 上游 GetPlaylistCategories
|
||||
* 返回 { source, list: [{ id, name, group, count }] },首项为 "全部"(id='')。
|
||||
*/
|
||||
export const getCategories = async() => {
|
||||
const body = await apiGet('/v1/tracklist/category', { appid: APP_ID })
|
||||
if (!apiOk(body)) throw new Error(body?.errmsg || `千千歌单分类获取失败(errno ${body?.errno})`)
|
||||
|
||||
const list: Array<{ id: string; name: string; group: string; count?: number }> = [
|
||||
{ id: '', name: '全部', group: '全部' },
|
||||
]
|
||||
for (const group of Array.isArray(body?.data) ? body.data : []) {
|
||||
const groupName = String(group?.categoryName ?? '').trim()
|
||||
for (const item of Array.isArray(group?.subCate) ? group.subCate : []) {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
const name = String(item?.categoryName ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 })
|
||||
}
|
||||
}
|
||||
return { source: 'qian' as const, list }
|
||||
}
|
||||
|
||||
/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */
|
||||
export const getCategoryPlaylists = async(categoryId: string | number, page = 1, limit = 30) => {
|
||||
const cateId = String(categoryId ?? '').trim()
|
||||
const pageNo = Math.max(1, Number(page) || 1)
|
||||
const pageSize = Math.min(100, Math.max(1, Number(limit) || 30))
|
||||
|
||||
const params: Record<string, any> = { appid: APP_ID, pageNo: String(pageNo), pageSize: String(pageSize) }
|
||||
if (cateId) params.subCateId = cateId
|
||||
|
||||
const body = await apiGet('/v1/tracklist/list', params)
|
||||
if (!apiOk(body)) throw new Error(body?.errmsg || `千千分类歌单获取失败(errno ${body?.errno})`)
|
||||
|
||||
const result = Array.isArray(body?.data?.result) ? body.data.result : []
|
||||
const list = result
|
||||
.map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId }))
|
||||
.filter((item: any) => item.id)
|
||||
return {
|
||||
source: 'qian' as const,
|
||||
list,
|
||||
total: Number(body?.data?.total) || list.length,
|
||||
page: pageNo,
|
||||
limit: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const patterns = [
|
||||
/music\.91q\.com\/(?:songlist|tracklist|playlist)\/([A-Za-z0-9]+)/,
|
||||
/(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)/,
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = pattern.exec(raw)
|
||||
if (match) return getPlaylistDetail(match[1])
|
||||
}
|
||||
if (raw && !raw.includes('/')) return getPlaylistDetail(raw)
|
||||
throw new Error('无效的千千歌单链接')
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// 千千音乐(music.91q.com / 91q.com)公共请求层
|
||||
// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign:
|
||||
// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
||||
// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源,
|
||||
// MusicServer 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选,未登录留空)。
|
||||
import crypto from 'node:crypto'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
|
||||
export const APP_ID = '16073360'
|
||||
export const SECRET = '0b50b02fd0d73a9c4c8c3a781c30845f'
|
||||
export const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'
|
||||
export const REFERER = 'https://music.91q.com/player'
|
||||
export const API_BASE = 'https://music.91q.com'
|
||||
|
||||
/** 上游音质档位顺序(3000 = flac,其余为 kbps) */
|
||||
export const RATE_ORDER = ['3000', '320', '128', '64']
|
||||
|
||||
/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */
|
||||
export const getQianCookie = (): string => {
|
||||
const cred = getCredentials('qian') as { cookie?: string } | null
|
||||
return cred?.cookie ? String(cred.cookie) : ''
|
||||
}
|
||||
|
||||
/** 上游 signParams:写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5 */
|
||||
export const signParams = (params: Record<string, any>): Record<string, any> => {
|
||||
const signed: Record<string, any> = { ...params, timestamp: String(Math.floor(Date.now() / 1000)) }
|
||||
const keys = Object.keys(signed).sort()
|
||||
const payload = keys.map((key) => `${key}=${signed[key]}`).join('&') + SECRET
|
||||
signed.sign = crypto.createHash('md5').update(payload, 'utf8').digest('hex')
|
||||
return signed
|
||||
}
|
||||
|
||||
export const signedUrl = (path: string, params: Record<string, any>): string =>
|
||||
`${API_BASE}${path}?${new URLSearchParams(signParams(params) as any).toString()}`
|
||||
|
||||
export const requestHeaders = (): Record<string, string> => {
|
||||
const cookie = getQianCookie()
|
||||
return {
|
||||
'User-Agent': USER_AGENT,
|
||||
Referer: REFERER,
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 发起带签名的 GET,返回可取消的请求对象;promise resolve 为响应 JSON body */
|
||||
export const apiRequest = (path: string, params: Record<string, any>) => {
|
||||
const req = httpFetch(signedUrl(path, params), { headers: requestHeaders(), timeout: 15000 })
|
||||
req.promise = req.promise.then(({ statusCode, body }: any) => {
|
||||
if (statusCode != 200) throw new Error(`千千音乐接口失败(HTTP ${statusCode})`)
|
||||
return body ?? {}
|
||||
})
|
||||
return req
|
||||
}
|
||||
|
||||
/** 发起带签名的 GET,promise resolve 为响应 JSON body */
|
||||
export const apiGet = (path: string, params: Record<string, any>): Promise<any> =>
|
||||
apiRequest(path, params).promise as Promise<any>
|
||||
|
||||
/** 上游状态判定:state=true 或 errno=22000 视为成功 */
|
||||
export const apiOk = (body: any): boolean => body?.state === true || Number(body?.errno) === 22000
|
||||
|
||||
/** tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path) */
|
||||
export const trackLink = (tsid: string, rate: string, register?: (req: any) => void): Promise<any> => {
|
||||
const req = apiRequest('/v1/song/tracklink', { TSID: tsid, appid: APP_ID, rate })
|
||||
register?.(req)
|
||||
return req.promise as Promise<any>
|
||||
}
|
||||
|
||||
/** 时间格式 mm:ss(duration 为秒;0 显示 0) */
|
||||
export const intervalOf = (duration: any): string | number => {
|
||||
const seconds = Number(duration)
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return 0
|
||||
const m = Math.trunc(seconds / 60)
|
||||
const s = Math.trunc(seconds % 60)
|
||||
return `${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}`
|
||||
}
|
||||
|
||||
/** 千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接) */
|
||||
export const joinArtists = (artists: any): string => {
|
||||
if (!Array.isArray(artists) || !artists.length) return ''
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const add = (name: any) => {
|
||||
const n = String(name ?? '').trim()
|
||||
if (!n || seen.has(n)) return
|
||||
seen.add(n)
|
||||
names.push(n)
|
||||
}
|
||||
for (const item of artists) if (Number(item?.artistType) === 38) add(item?.name)
|
||||
if (!names.length) for (const item of artists) add(item?.name)
|
||||
return names.join('、')
|
||||
}
|
||||
|
||||
/** 上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P */
|
||||
export const normalizeAlbumAssetCode = (id: any): string => {
|
||||
const s = String(id ?? '').trim()
|
||||
if (s.length < 2) return ''
|
||||
return s[0] === 'p' || s[0] === 'P' ? `P${s.slice(1)}` : ''
|
||||
}
|
||||
|
||||
/** 上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(服务端 23001 时重试用) */
|
||||
export const sanitizeAlbumKeyword = (keyword: any): string => {
|
||||
const trimmed = String(keyword ?? '').trim()
|
||||
if (!trimmed) return ''
|
||||
return trimmed
|
||||
.replace(/[::"'“”‘’()()[\]【】,,/\\\-.]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
export const firstNonEmpty = (...values: any[]): string => {
|
||||
for (const value of values) {
|
||||
const s = String(value ?? '').trim()
|
||||
if (s) return s
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD) */
|
||||
export const releaseDate10 = (raw: any): string => {
|
||||
const s = String(raw ?? '').trim()
|
||||
return s.length >= 10 ? s.slice(0, 10) : s
|
||||
}
|
||||
|
||||
/** 上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps) */
|
||||
export const rateStats = (rateFileInfo: any, duration: number): { size: number; bitrate: number } => {
|
||||
for (const rate of RATE_ORDER) {
|
||||
const info = rateFileInfo?.[rate]
|
||||
const size = Number(info?.size ?? 0)
|
||||
if (!info || !(size > 0)) continue
|
||||
if (duration > 0) return { size, bitrate: Math.trunc((size * 8) / 1000 / duration) }
|
||||
return { size, bitrate: rate === '3000' ? 800 : Number(rate) }
|
||||
}
|
||||
return { size: 0, bitrate: 0 }
|
||||
}
|
||||
|
||||
// 千千 rateFileInfo → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
|
||||
const RATE_LABEL: Record<string, '128k' | '320k' | 'flac'> = { '128': '128k', '320': '320k', '3000': 'flac' }
|
||||
|
||||
export const rateTypes = (rateFileInfo: any) => {
|
||||
const types: Array<{ type: '128k' | '320k' | 'flac'; size: string }> = []
|
||||
const _types: Partial<Record<'128k' | '320k' | 'flac', { size: string }>> = {}
|
||||
for (const rate of ['128', '320', '3000']) {
|
||||
const info = rateFileInfo?.[rate]
|
||||
const size = Number(info?.size ?? 0)
|
||||
if (!info || !(size > 0)) continue
|
||||
const label = RATE_LABEL[rate]
|
||||
const sizeText = sizeMB(size)
|
||||
types.push({ type: label, size: sizeText })
|
||||
_types[label] = { size: sizeText }
|
||||
}
|
||||
return { types, _types }
|
||||
}
|
||||
|
||||
const sizeMB = (size: number): string => {
|
||||
if (!size) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)))
|
||||
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
|
||||
}
|
||||
|
||||
export interface MapTrackOptions {
|
||||
albumId?: string
|
||||
albumName?: string
|
||||
img?: string | null
|
||||
singer?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 千千曲目字段 → MusicServer 统一 Song 形状
|
||||
* 搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
|
||||
* 歌单详情用 TSID + albumTitle + pic。
|
||||
* 注:上游 Search 会跳过 isVip != 0 的曲目;MusicServer 聚合列表保留它们并标记 payPlay,
|
||||
* 取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
|
||||
*/
|
||||
export const mapTrack = (item: any, opts: MapTrackOptions = {}) => {
|
||||
const duration = Number(item?.duration ?? 0)
|
||||
const stats = rateStats(item?.rateFileInfo, duration)
|
||||
const { types, _types } = rateTypes(item?.rateFileInfo)
|
||||
const albumId = firstNonEmpty(normalizeAlbumAssetCode(item?.albumAssetCode), opts.albumId)
|
||||
return {
|
||||
songmid: firstNonEmpty(item?.TSID, item?.assetId, item?.tsid),
|
||||
name: String(item?.title ?? '').trim(),
|
||||
singer: firstNonEmpty(joinArtists(item?.artist), opts.singer),
|
||||
albumId,
|
||||
albumName: firstNonEmpty(item?.albumTitle, opts.albumName),
|
||||
interval: intervalOf(duration),
|
||||
size: stats.size || 0,
|
||||
bitrate: stats.bitrate || 0,
|
||||
payPlay: Number(item?.isVip ?? 0) !== 0 || Number(item?.isPaid ?? 0) !== 0,
|
||||
img: firstNonEmpty(item?.pic, opts.img) || null,
|
||||
lrc: null,
|
||||
lrcx: '',
|
||||
types,
|
||||
_types,
|
||||
source: 'qian' as const,
|
||||
}
|
||||
}
|
||||
|
||||
export const songLink = (tsid: string): string => `https://music.91q.com/song/${tsid}`
|
||||
export const albumLink = (id: string): string => `https://music.91q.com/album/${id}`
|
||||
export const playlistLink = (id: string): string => `https://music.91q.com/songlist/${id}`
|
||||
|
||||
export default {
|
||||
APP_ID,
|
||||
SECRET,
|
||||
getUserAgent: () => USER_AGENT,
|
||||
getQianCookie,
|
||||
signParams,
|
||||
signedUrl,
|
||||
apiRequest,
|
||||
apiOk,
|
||||
trackLink,
|
||||
mapTrack,
|
||||
rateTypes,
|
||||
rateStats,
|
||||
sanitizeAlbumKeyword,
|
||||
normalizeAlbumAssetCode,
|
||||
}
|
||||
+18
-1
@@ -151,4 +151,21 @@ export const getDevice = async (): Promise<QsDevice> => {
|
||||
export const hasDevice = (): boolean =>
|
||||
Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id)
|
||||
|
||||
export default { registerDevice, getDevice, hasDevice }
|
||||
/**
|
||||
* 设备身份(同步读,不触发注册):已注册设备优先;尚未注册时用独立生成的
|
||||
* `_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 }
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
// 汽水官方 PC 客户端签名(X-Helios / X-Medusa)—— 可选增强,缺原生模块时整体降级
|
||||
//
|
||||
// 原理:直接复用官方客户端自带的原生模块 bdms.node(官方 main 进程就是
|
||||
// bdms.init({ deviceId }) → generateHttpSignatureHeaders(url, "名字\r\n值\r\n…"),
|
||||
// 把返回的 name/value 成对写回请求头)。与 sodahub-org/libmssdk 同一套做法,
|
||||
// 区别是我们进程内加载,不另起 HTTP 签名服务。
|
||||
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
|
||||
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
|
||||
//
|
||||
// 原生文件(官方客户端二进制,专有、不入库、不再分发)放置位置任选其一:
|
||||
// 1) 环境变量 QS_MSSDK_DIR 指向同时含 bdms.node 与 metasecml.dll 的目录
|
||||
// 2) 仓库根 soda-mssdk/(已 gitignore)
|
||||
// 3) 本机已安装的官方客户端:%LOCALAPPDATA%\Programs\Soda Music\<版本>\resources\app.asar.unpacked
|
||||
// 从安装包提取:官方 SodaMusic-v*-official-win32_x64.exe → Bandizip 解 NSIS →
|
||||
// $Var31/app.7z → resources/app.asar.unpacked/{bdms.node, metasecml.dll}
|
||||
import { createRequire } from 'node:module'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { getDeviceIds } from './device.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
const MODULE_NAME = 'bdms.node'
|
||||
const CLIENT_DIR_NAMES = ['Soda Music', 'SodaMusic', '汽水音乐']
|
||||
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
||||
|
||||
const findModuleDir = (): string | null => {
|
||||
const candidates: string[] = []
|
||||
if (process.env.QS_MSSDK_DIR) candidates.push(process.env.QS_MSSDK_DIR)
|
||||
candidates.push(join(REPO_ROOT, 'soda-mssdk'))
|
||||
const localAppData = process.env.LOCALAPPDATA
|
||||
if (localAppData) {
|
||||
for (const name of CLIENT_DIR_NAMES) {
|
||||
const root = join(localAppData, 'Programs', name)
|
||||
if (!existsSync(root)) continue
|
||||
try {
|
||||
for (const ver of readdirSync(root)) candidates.push(join(root, ver, 'resources', 'app.asar.unpacked'))
|
||||
} catch {
|
||||
// 读不到目录就跳过
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates.find(dir => dir && existsSync(join(dir, MODULE_NAME))) ?? null
|
||||
}
|
||||
|
||||
interface BdmsModule {
|
||||
init: (options: { deviceId: string }) => unknown
|
||||
generateHttpSignatureHeaders: (url: string, headersLines: string) => string
|
||||
}
|
||||
|
||||
let signer: { module: BdmsModule; deviceId: string; dir: string } | null | undefined
|
||||
|
||||
/** 懒加载官方签名模块(失败只提示一次,之后一律按不可用处理) */
|
||||
const loadSigner = () => {
|
||||
if (signer !== undefined) return signer
|
||||
signer = null
|
||||
try {
|
||||
const dir = findModuleDir()
|
||||
if (!dir) return signer
|
||||
const mod = require(join(dir, MODULE_NAME)) as BdmsModule
|
||||
if (typeof mod?.init !== 'function' || typeof mod?.generateHttpSignatureHeaders !== 'function') {
|
||||
console.log(`[qs/mssdk] ${MODULE_NAME} 导出不符合预期,按不可用处理`)
|
||||
return signer
|
||||
}
|
||||
const { deviceId } = getDeviceIds()
|
||||
mod.init({ deviceId })
|
||||
signer = { module: mod, deviceId, dir }
|
||||
console.log(`[qs/mssdk] 官方签名模块已加载: ${dir} (device_id=${deviceId})`)
|
||||
} catch (err: any) {
|
||||
console.log(`[qs/mssdk] 官方签名模块不可用,降级: ${err?.message ?? err}`)
|
||||
}
|
||||
return signer
|
||||
}
|
||||
|
||||
/** 官方签名是否可用(不影响主流程,仅决定走 PC 通道还是安卓通道) */
|
||||
export const mssdkAvailable = (): boolean => Boolean(loadSigner())
|
||||
|
||||
/** 签名时绑定的设备(与请求 URL 里的 device_id/iid 必须一致) */
|
||||
export const mssdkDeviceId = (): string => loadSigner()?.deviceId ?? ''
|
||||
|
||||
/**
|
||||
* 给一个 qishui.com 请求签名
|
||||
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
|
||||
* @returns 需追加的头;不可用时返回 null(调用方走原通道)
|
||||
*/
|
||||
export const signQishuiRequest = (url: string, headers: Record<string, string>): Record<string, string> | null => {
|
||||
const current = loadSigner()
|
||||
if (!current) return null
|
||||
try {
|
||||
const lines = Object.entries(headers)
|
||||
.filter(([name, value]) => name && value != null)
|
||||
.map(([name, value]) => `${name}\r\n${value}`)
|
||||
.join('\r\n')
|
||||
const fields = String(current.module.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(Boolean)
|
||||
const signed: Record<string, string> = {}
|
||||
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
|
||||
if (!signed['X-Helios'] && !signed['x-helios']) return null
|
||||
return signed
|
||||
} catch (err: any) {
|
||||
console.log(`[qs/mssdk] 签名失败,本次降级: ${err?.message ?? err}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 官方 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 { deviceId, 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, mssdkDeviceId, signQishuiRequest, pcCommonParams, PC_UA }
|
||||
+37
-17
@@ -1,10 +1,13 @@
|
||||
// 汽水音乐搜索(App 搜索接口直连)
|
||||
// 接口: https://api.qishui.com/luna/search/track?<安卓设备参数>&q=<关键词>&cursor=&count=
|
||||
// 实测(2026-10-01)匿名可搜索;关键在安卓 App 的设备参数指纹——
|
||||
// 缺参数或用 PC 端参数(device_platform=windows)上游会静默风控,返回 200 + 空 body
|
||||
// 接口: https://api.qishui.com/luna/search/track?<公共参数>&q=<关键词>&cursor=
|
||||
// 两条通道(同一接口,差在公共参数与签名,返回结构一致):
|
||||
// 官方 PC 通道: 装好官方签名模块(sdk/qs/mssdk.ts)时优先——luna_pc 参数 + X-Helios/X-Medusa;
|
||||
// 安卓指纹通道: 未装签名模块时回退——静态安卓设备参数,缺参数或用 PC 参数不签名会被静默风控
|
||||
// (200 + 空 body);被风控时需换新指纹。
|
||||
import { sizeFormate, formatPlayTime } from '../index.ts'
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { coverUrl } from './musicInfo.ts'
|
||||
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from './mssdk.ts'
|
||||
|
||||
// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新)
|
||||
const SEARCH_DEVICE_PARAMS = {
|
||||
@@ -54,15 +57,29 @@ const SEARCH_DEVICE_PARAMS = {
|
||||
}
|
||||
|
||||
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
|
||||
const PC_PAGE_SIZE = 20
|
||||
|
||||
// 有官方签名模块时走官方 PC 通道(PC 参数必须配 X-Helios/X-Medusa,否则上游 200 + 空 body)
|
||||
const buildSearchUrl = (str: string, cursor: number, count: number) => {
|
||||
const params = new URLSearchParams({
|
||||
...SEARCH_DEVICE_PARAMS,
|
||||
_rticket: String(Date.now()),
|
||||
q: str,
|
||||
cursor: String(cursor),
|
||||
count: String(count),
|
||||
})
|
||||
const params = mssdkAvailable()
|
||||
? 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()}`
|
||||
}
|
||||
|
||||
@@ -103,13 +120,16 @@ export default {
|
||||
},
|
||||
search(str, page = 1, limit, retryNum = 0) {
|
||||
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||
if (limit == null) limit = this.limit
|
||||
const cursor = limit * (page - 1)
|
||||
const searchRequest = httpFetch(buildSearchUrl(str, cursor, limit), {
|
||||
method: 'get',
|
||||
headers: { 'User-Agent': SEARCH_UA },
|
||||
timeout: 15000,
|
||||
})
|
||||
const pcChannel = mssdkAvailable()
|
||||
// PC 通道每页固定 20 条(上游默认、不接受 count),cursor 必须按 20 步进,否则翻页与 limit 错位重叠
|
||||
const pageSize = pcChannel ? PC_PAGE_SIZE : (limit ?? this.limit)
|
||||
const cursor = pageSize * (page - 1)
|
||||
const url = buildSearchUrl(str, cursor, pageSize)
|
||||
// 签名覆盖真实发出的头:先定头、再签、再发
|
||||
const headers: Record<string, string> = { 'User-Agent': pcChannel ? PC_UA : SEARCH_UA }
|
||||
const signed = signQishuiRequest(url, headers)
|
||||
if (signed) Object.assign(headers, signed)
|
||||
const searchRequest = httpFetch(url, { method: 'get', headers, timeout: 15000 })
|
||||
return searchRequest.promise.then(({ body }) => {
|
||||
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}),重试
|
||||
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
|
||||
|
||||
+159
-55
@@ -1,12 +1,21 @@
|
||||
// 汽水音乐扫码登录 —— 抖音 passport web 二维码(须用「抖音 APP」扫码,汽水 App 扫可能不完成确认)
|
||||
// 汽水音乐扫码登录 —— 复刻官方 PC 客户端(SodaMusic)的扫码链路
|
||||
// 接口:
|
||||
// GET https://api.qishui.com/passport/web/get_qrcode/ → token + qrcode 图片 + 初始化 Cookie
|
||||
// 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 登录成功(自动保存)
|
||||
// 状态映射沿用本仓库约定: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',
|
||||
@@ -18,12 +27,30 @@ const PASSPORT_FIXED = {
|
||||
need_short_url: 'false',
|
||||
is_frontier: 'true',
|
||||
is_new_login: '1',
|
||||
iid: '27960026095955',
|
||||
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': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
|
||||
'User-Agent': WEB_UA,
|
||||
...(cookie ? { Cookie: cookie } : {}),
|
||||
})
|
||||
|
||||
@@ -35,28 +62,59 @@ const cookieValues = (headers: Record<string, any>): string[] => {
|
||||
const cookiePairsFrom = (headers: Record<string, any>): string[] =>
|
||||
cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean)
|
||||
|
||||
const sessionIdFrom = (headers: Record<string, any>): string => {
|
||||
for (const value of cookieValues(headers)) {
|
||||
const match = String(value).match(/(?:^|[;,]\s*)sessionid=([^;,\s]+)/i)
|
||||
if (match) return match[1]
|
||||
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 ''
|
||||
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>()
|
||||
|
||||
/** 创建扫码登录二维码 */
|
||||
export const createQr = async (): Promise<{ token: string; qrimg: string; expireTime: number; scanApp: 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({
|
||||
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,
|
||||
...commonParams(),
|
||||
next: PASSPORT_FIXED.next,
|
||||
need_logo: PASSPORT_FIXED.need_logo,
|
||||
need_short_url: PASSPORT_FIXED.need_short_url,
|
||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||
})
|
||||
const request = httpFetch(`${PASSPORT_HOST}/passport/web/get_qrcode/?${query}`, {
|
||||
method: 'get',
|
||||
@@ -68,38 +126,64 @@ export const createQr = async (): Promise<{ token: string; qrimg: string; expire
|
||||
if (statusCode != 200 || !data.token) {
|
||||
throw new Error(`获取二维码失败: HTTP ${statusCode} ${JSON.stringify(body).slice(0, 120)}`)
|
||||
}
|
||||
qrCookies.set(data.token, cookiePairsFrom(headers).join('; '))
|
||||
|
||||
// qrcode 可能是 data URL,也可能是图片链接——后者取回转 data URL 方便直接展示
|
||||
let qrimg = String(data.qrcode || '')
|
||||
if (qrimg && !qrimg.startsWith('data:')) {
|
||||
try {
|
||||
const imgRes = await httpFetch(qrimg, { method: 'get', timeout: 15000 }).promise
|
||||
qrimg = `data:image/png;base64,${imgRes.raw.toString('base64')}`
|
||||
} catch {
|
||||
// 取图失败时退回原始链接,由调用方处理
|
||||
}
|
||||
}
|
||||
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,
|
||||
scanApp: String(data.copywriting || '').includes('抖音') ? 'douyin' : '',
|
||||
// 官方 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({
|
||||
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,
|
||||
iid: PASSPORT_FIXED.iid,
|
||||
})
|
||||
const query = new URLSearchParams(commonParams())
|
||||
const form = new URLSearchParams({
|
||||
need_logo: PASSPORT_FIXED.need_logo,
|
||||
need_short_url: PASSPORT_FIXED.need_short_url,
|
||||
@@ -108,12 +192,11 @@ export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
||||
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||
next: PASSPORT_FIXED.next,
|
||||
})
|
||||
const cookie = qrCookies.get(token) || ''
|
||||
const request = httpFetch(`${PASSPORT_HOST}/passport/web/check_qrconnect/?${query}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...webHeaders(cookie),
|
||||
...webHeaders(qrCookies.get(token) || ''),
|
||||
},
|
||||
body: form.toString(),
|
||||
timeout: 15000,
|
||||
@@ -121,33 +204,51 @@ export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
||||
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 sessionid = sessionIdFrom(headers)
|
||||
const status = String(data.status ?? '')
|
||||
const errorCode = Number(data.error_code) || 0
|
||||
const decision = decideQrStatus(data)
|
||||
|
||||
if (sessionid) {
|
||||
setCredentials('qs', { sessionid })
|
||||
// 采集本次轮询下发的 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,
|
||||
errorCode,
|
||||
status: decision.status,
|
||||
errorCode: decision.errorCode,
|
||||
}
|
||||
}
|
||||
|
||||
// 未确认:区分等待/已扫码/过期。上游状态语义未完全公开,按观察到的值映射,未知值一律视为等待
|
||||
const expired = errorCode === 4 || /expire|过期|失效/i.test(String(data.description ?? ''))
|
||||
if (expired) {
|
||||
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: '二维码已失效', status, errorCode }
|
||||
return { code: 800, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
if (/scan/i.test(status) || errorCode === 205) {
|
||||
return { code: 802, message: '已扫码,待确认', status, errorCode }
|
||||
if (decision.state === 'scanned' || decision.state === 'mfa') {
|
||||
return { code: 802, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
return { code: 801, message: '等待扫码', status, errorCode }
|
||||
return { code: 801, message: decision.message, status: decision.status, errorCode: decision.errorCode }
|
||||
}
|
||||
|
||||
/** 当前登录态 */
|
||||
@@ -159,4 +260,7 @@ export const status = (): { logged: boolean; updatedAt?: string } => {
|
||||
/** 登录态 sessionid(未登录返回空串) */
|
||||
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
|
||||
|
||||
export default { createQr, checkQr, status, getSessionId }
|
||||
/** 登录态完整 Cookie(未登录返回空串;取流优先用它,sessionid 只是最小集) */
|
||||
export const getCookie = (): string => (getCredentials('qs') as any)?.cookie || ''
|
||||
|
||||
export default { createQr, checkQr, status, getSessionId, getCookie, buildScanUrl, decideQrStatus }
|
||||
+133
-31
@@ -1,45 +1,147 @@
|
||||
// QQ音乐专辑(上游 music-lib/qq/album.go + qq.go 的 fetchAlbumDetail 移植)
|
||||
// 搜索:c.y.qq.com soso search_for_qq_cp t=8(上游 SearchAlbum)
|
||||
// 曲目:music.musichallAlbum.AlbumSongList GetAlbumSongList 按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
|
||||
// 详情:GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,封面走平台固定 URL 规则
|
||||
// 链接解析:albumDetail/<mid>、album/<mid>、albummid=<mid>(上游 ParseAlbum)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { filterMusicInfoItem } from './singer.ts'
|
||||
import { createMusicuFetch } from './utils/index.ts'
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 通过专辑 mid 获取专辑详情与曲目列表
|
||||
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
async getAlbumDetail(id, page = 1, limit = 100) {
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
const ALBUM_BATCH = 100
|
||||
|
||||
export const filterSongList = (raw: any[]) =>
|
||||
(raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
|
||||
|
||||
const albumCover = (mid: string) => `https://y.gtimg.cn/music/photo_new/T002R500x500M000${mid}.jpg`
|
||||
const albumLink = (mid: string) => `https://y.qq.com/n/ryqq/albumDetail/${mid}`
|
||||
|
||||
/** 专辑曲目全量(上游 fetchAlbumDetail 的曲目段:AlbumSongList 100 一批翻页) */
|
||||
export const fetchAlbumSongs = async(albumId: string) => {
|
||||
const albumMid = String(albumId ?? '').trim()
|
||||
if (!albumMid) throw new Error('tx 专辑 id 为空')
|
||||
|
||||
const songs: any[] = []
|
||||
let totalNum = 0
|
||||
for (let begin = 0; ; begin += ALBUM_BATCH) {
|
||||
const body = await createMusicuFetch({
|
||||
req: {
|
||||
module: 'music.musichallAlbum.AlbumSongList',
|
||||
method: 'GetAlbumSongList',
|
||||
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝
|
||||
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
|
||||
param: { albumMid, begin, num: ALBUM_BATCH, order: 2 },
|
||||
},
|
||||
})
|
||||
if (body.req.code != 0) throw new Error('get album song list faild.')
|
||||
const data = body.req.data ?? {}
|
||||
const list = data.songList ?? []
|
||||
if (!totalNum) totalNum = Number(data.totalNum ?? 0) || 0
|
||||
if (!list.length) break
|
||||
songs.push(...list)
|
||||
if (list.length < ALBUM_BATCH) break
|
||||
if (totalNum > 0 && begin + list.length >= totalNum) break
|
||||
}
|
||||
const meta: any = songs[0]?.songInfo ?? null
|
||||
return {
|
||||
raw: songs,
|
||||
songs: filterSongList(songs),
|
||||
total: totalNum || songs.length,
|
||||
albumMid,
|
||||
albumName: meta?.album?.name ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const data = body.req.data
|
||||
const songList = data.songList ?? []
|
||||
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,
|
||||
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则
|
||||
const first = songList[0]?.songInfo
|
||||
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))]
|
||||
/**
|
||||
* 通过专辑 mid 获取专辑详情与曲目列表
|
||||
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
export const getAlbumDetail = async(id: string, page = 1, limit = 100) => {
|
||||
const body = await createMusicuFetch({
|
||||
req: {
|
||||
module: 'music.musichallAlbum.AlbumSongList',
|
||||
method: 'GetAlbumSongList',
|
||||
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝
|
||||
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
|
||||
},
|
||||
})
|
||||
if (body.req.code != 0) throw new Error('get album song list faild.')
|
||||
|
||||
const data = body.req.data
|
||||
const songList = data.songList ?? []
|
||||
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,
|
||||
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则
|
||||
const first = songList[0]?.songInfo
|
||||
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))]
|
||||
return {
|
||||
source: 'tx',
|
||||
info: {
|
||||
name: first?.album?.name ?? null,
|
||||
img: albumCover(String(id)),
|
||||
desc: null,
|
||||
author: authors.join('、') || null,
|
||||
},
|
||||
list: filterSongList(songList),
|
||||
page,
|
||||
limit,
|
||||
total: data.totalNum,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAlbumSongs = async(id: string) => (await fetchAlbumSongs(id)).songs
|
||||
|
||||
/** 专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const url = `http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p=${page}&n=${limit}&w=${encodeURIComponent(String(keyword ?? ''))}&t=8`
|
||||
const { statusCode, body } = await httpFetch(url, {
|
||||
headers: { 'User-Agent': UA, Referer: 'https://y.qq.com/portal/search.html' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || !body?.data?.album) throw new Error('tx 专辑搜索失败')
|
||||
|
||||
const rawList: any[] = body.data.album.list ?? []
|
||||
const list = rawList.map(item => {
|
||||
const mid = String(item?.albumMID ?? '').trim()
|
||||
if (!mid) return null
|
||||
return {
|
||||
source: 'tx',
|
||||
info: {
|
||||
name: first?.album?.name ?? null,
|
||||
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`,
|
||||
desc: null,
|
||||
author: authors.join('、') || null,
|
||||
},
|
||||
list: this.filterSongList(songList),
|
||||
page,
|
||||
limit,
|
||||
total: data.totalNum,
|
||||
id: mid,
|
||||
name: String(item?.albumName ?? '').trim(),
|
||||
cover: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${mid}.jpg`,
|
||||
trackCount: Number(item?.song_count ?? item?.songCount ?? 0) || 0,
|
||||
creator: String(item?.singerName ?? '').trim(),
|
||||
description: '',
|
||||
link: albumLink(mid),
|
||||
type: 'album',
|
||||
albumId: String(item?.albumID ?? ''),
|
||||
publishTime: String(item?.publicTime ?? ''),
|
||||
source: 'tx' as const,
|
||||
}
|
||||
},
|
||||
filterSongList(raw) {
|
||||
return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
|
||||
},
|
||||
}).filter(Boolean)
|
||||
const total = Number(body.data.album.totalnum ?? body.data.album.total ?? list.length) || list.length
|
||||
return { list, page, limit, total, source: 'tx' as const }
|
||||
}
|
||||
|
||||
const ALBUM_PATTERNS = [
|
||||
/albumDetail\/([A-Za-z0-9]+)/,
|
||||
/\/album\/([A-Za-z0-9]+)/,
|
||||
/albummid=([A-Za-z0-9]+)/,
|
||||
]
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
const raw = String(link ?? '')
|
||||
for (const pattern of ALBUM_PATTERNS) {
|
||||
const match = pattern.exec(raw)
|
||||
if (match) return getAlbumDetail(match[1]!)
|
||||
}
|
||||
throw new Error('无效的QQ音乐专辑链接')
|
||||
}
|
||||
|
||||
export default {
|
||||
getAlbumDetail,
|
||||
getAlbumSongs,
|
||||
searchAlbum,
|
||||
parseAlbumLink,
|
||||
fetchAlbumSongs,
|
||||
filterSongList,
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import userPlaylist from './userPlaylist.ts'
|
||||
|
||||
const tx = {
|
||||
leaderboard,
|
||||
@@ -16,6 +19,9 @@ const tx = {
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
playlist,
|
||||
parse,
|
||||
userPlaylist,
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return txApi.getMusicUrl(songInfo, type)
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// QQ音乐链接解析(上游 music-lib/qq/song.go 的 Parse 移植)
|
||||
// 歌曲链接形态:
|
||||
// https://y.qq.com/n/ryqq/songDetail/<songmid>
|
||||
// https://y.qq.com/n/yqq/song/<songmid>.html
|
||||
// https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
|
||||
// ...?songmid=<songmid>
|
||||
// 返回 Song(含 songmid);直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致)。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import txApi from '../apis/tx.ts'
|
||||
import { parseAlbumLink } from './album.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
|
||||
const SONG_DETAIL_RE = /songDetail\/([A-Za-z0-9]+)/
|
||||
const SONG_PATH_RE = /\/song\/([A-Za-z0-9]+)/
|
||||
const SONG_MID_QUERY_RE = /[?&]songmid=([A-Za-z0-9]+)/
|
||||
const SONG_ID_QUERY_RE = /[?&]songid=(\d+)/
|
||||
|
||||
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
|
||||
|
||||
/** 上游 fetchSongDetailByID:数值 songid → mid */
|
||||
const fetchMidBySongId = async(songid: string) => {
|
||||
const { statusCode, body } = await httpFetch(
|
||||
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songid=${encodeURIComponent(songid)}&format=json`,
|
||||
{ headers: { 'User-Agent': UA, Referer: 'http://m.y.qq.com' }, timeout: 15000 },
|
||||
).promise
|
||||
const mid = statusCode === 200 ? body?.data?.[0]?.mid : ''
|
||||
if (!mid) throw new Error('QQ音乐歌曲不存在')
|
||||
return String(mid)
|
||||
}
|
||||
|
||||
/** 上游 fetchSongDetail:songmid → 原始条目(顺带拿 file 体积信息) */
|
||||
const fetchSongDetailRaw = async(songmid: string) => {
|
||||
const { statusCode, body } = await httpFetch(
|
||||
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songmid=${encodeURIComponent(songmid)}&format=json`,
|
||||
{ headers: { 'User-Agent': UA, Referer: 'http://m.y.qq.com' }, timeout: 15000 },
|
||||
).promise
|
||||
const item = statusCode === 200 ? body?.data?.[0] : null
|
||||
if (!item?.mid) throw new Error('QQ音乐歌曲不存在')
|
||||
return item
|
||||
}
|
||||
|
||||
/** 原始条目 → MusicServer Song 形状 */
|
||||
const convertSong = (item: any) => {
|
||||
const file = item?.file ?? {}
|
||||
const types: any[] = []
|
||||
const _types: Record<string, { size: string }> = {}
|
||||
const push = (type: string, size: any) => {
|
||||
if (typeof size === 'number' && size > 0) {
|
||||
const formatted = sizeFormate(size)
|
||||
types.push({ type, size: formatted })
|
||||
_types[type] = { size: formatted }
|
||||
}
|
||||
}
|
||||
push('128k', file.size_128mp3)
|
||||
push('320k', file.size_320mp3)
|
||||
push('flac', file.size_flac)
|
||||
push('flac24bit', file.size_hires)
|
||||
|
||||
const albumMid = item?.album?.mid ?? ''
|
||||
const singer = formatSingerName(item?.singer, 'name')
|
||||
return {
|
||||
source: 'tx' as const,
|
||||
songmid: String(item.mid),
|
||||
songId: item.id,
|
||||
name: item.title ?? item.name ?? '',
|
||||
singer,
|
||||
albumId: albumMid,
|
||||
albumMid,
|
||||
albumName: item?.album?.name ?? '',
|
||||
interval: formatPlayTime(item?.interval ?? 0),
|
||||
img: albumMid ? `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumMid}.jpg` : '',
|
||||
lrc: null,
|
||||
strMediaMid: file.media_mid ?? item.mid,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '')
|
||||
let songmid = SONG_DETAIL_RE.exec(raw)?.[1] ?? SONG_PATH_RE.exec(raw)?.[1] ?? ''
|
||||
let songid = ''
|
||||
if (!songmid) {
|
||||
songid = SONG_ID_QUERY_RE.exec(raw)?.[1] ?? ''
|
||||
songmid = SONG_MID_QUERY_RE.exec(raw)?.[1] ?? ''
|
||||
if (!songmid && songid) songmid = await fetchMidBySongId(songid)
|
||||
}
|
||||
if (!songmid) throw new Error('无效的QQ音乐歌曲链接')
|
||||
|
||||
const item = await fetchSongDetailRaw(songmid)
|
||||
const song: any = convertSong(item)
|
||||
if (songid) song.songId = Number(songid)
|
||||
try {
|
||||
const hit = await txApi.getMusicUrl(song, '320k').promise
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
} catch {
|
||||
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,272 @@
|
||||
// QQ音乐歌单(上游 music-lib/qq/playlist.go + qq.go 的 fetchPlaylistDetail 移植)
|
||||
// 搜索/分类复用现有 sdk/tx/songList.ts(handleParseId / getListId / getListDetail / getTag / getList)
|
||||
// 推荐:musicu.fcg playlist.HotRecommendServer/get_hot_recommend(上游 GetRecommendedPlaylists)
|
||||
// 链接解析:extractPlaylistId 为上游 extractQQPlaylistID 的 1:1 移植(含单测用例断言)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import songList from './songList.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
// get_category_content 固定页大小(songList.limit_list = 36,运行时读取以避免模块环上的 TDZ)
|
||||
const apiLimit = () => Number(songList.limit_list) || 36
|
||||
|
||||
const playlistLink = (id: string) => `https://y.qq.com/n/ryqq/playlist/${id}`
|
||||
|
||||
/** 歌单摘要:兼容既有 songList.filterList/filterList2 字段并补 spec 形状 */
|
||||
const toSummary = (item: any) => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const cover = String(item?.img ?? item?.cover ?? '')
|
||||
const trackCount = Number(item?.total ?? item?.trackCount ?? 0) || 0
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover,
|
||||
trackCount,
|
||||
creator: item?.author ?? item?.creator ?? '',
|
||||
description: item?.desc ?? item?.description ?? '',
|
||||
link: playlistLink(id),
|
||||
source: 'tx' as const,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const result = await songList.search(String(keyword ?? ''), page, limit)
|
||||
const list = (result?.list ?? []).map(toSummary).filter(Boolean)
|
||||
return { list, page, limit, total: result?.total ?? list.length, source: 'tx' as const }
|
||||
}
|
||||
|
||||
/** 歌单详情(上游 fetchPlaylistDetail;复用 songList.getListDetail,按 page/limit 切片) */
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const detail = await songList.getListDetail(String(id ?? ''))
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return {
|
||||
source: 'tx' as const,
|
||||
info: detail.info,
|
||||
list: detail.list.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: detail.total ?? detail.list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单曲目全量(上游 GetPlaylistSongs) */
|
||||
export const getPlaylistSongs = async(id: string) => {
|
||||
const detail = await songList.getListDetail(String(id ?? ''))
|
||||
return detail.list
|
||||
}
|
||||
|
||||
/** 歌单分类(上游 GetPlaylistCategories;复用 songList.getTag / getHotTag) */
|
||||
export const getCategories = async() => {
|
||||
const [groups, hotTags] = await Promise.all([
|
||||
songList.getTag().catch(() => []),
|
||||
songList.getHotTag().catch(() => []),
|
||||
])
|
||||
const hotSet = new Set((hotTags ?? []).map((item: any) => String(item.id)))
|
||||
const list: any[] = [{ id: '', name: '全部', group: '全部' }]
|
||||
const seen = new Set<string>([''])
|
||||
for (const group of groups ?? []) {
|
||||
for (const item of group?.list ?? []) {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
const name = String(item?.name ?? '').trim()
|
||||
if (!id || !name || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
list.push({ id, name, group: group.name ?? '', hot: hotSet.has(id) })
|
||||
}
|
||||
}
|
||||
return { source: 'tx' as const, list }
|
||||
}
|
||||
|
||||
/** 分类歌单(上游 GetCategoryPlaylists;get_category_content 每页 36 条,按入参 limit 重新切片)
|
||||
* 直接解析原始响应:song_cnt/play_cnt(分类)与 song_ids/access_num(全部)在既有
|
||||
* songList.filterList/filterList2 中未完整映射,这里按上游字段补齐后再切片(不改动既有 songList 行为)。
|
||||
* categoryId 为空或 '10000000'(上游「全部」默认值)时走 get_playlist_by_tag 广场分支。 */
|
||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||
const cat = String(categoryId ?? '').trim()
|
||||
const isAll = cat === '' || cat === '10000000'
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
const apiPages = Math.max(1, Math.ceil((start + limit) / apiLimit()))
|
||||
const items: any[] = []
|
||||
let total = 0
|
||||
for (let apiPage = 1; apiPage <= apiPages; apiPage++) {
|
||||
const url = isAll ? songList.getListUrl('5', '', apiPage) : songList.getListUrl('5', cat, apiPage)
|
||||
const { statusCode, body } = await httpFetch(url, { timeout: 15000 }).promise
|
||||
if (statusCode !== 200 || body?.code != 0) throw new Error(`tx 分类歌单获取失败 (code ${body?.code ?? statusCode})`)
|
||||
const data = body?.playlist?.data ?? {}
|
||||
if (isAll) {
|
||||
total = Number(data.total ?? total) || total
|
||||
const batch = (data.v_playlist ?? []).map((item: any) => {
|
||||
const playCount = Number(item?.access_num ?? 0) || 0
|
||||
return {
|
||||
id: String(item?.tid ?? '').trim(),
|
||||
name: String(item?.title ?? '').trim(),
|
||||
cover: String(item?.cover_url_medium || item?.cover_url_big || ''),
|
||||
creator: item?.creator_info?.nick ?? '',
|
||||
trackCount: Array.isArray(item?.song_ids) ? item.song_ids.length : 0,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
description: String(item?.desc ?? '').replace(/<br>/g, '\n'),
|
||||
}
|
||||
}).filter((item: any) => item.id && item.name)
|
||||
items.push(...batch)
|
||||
if (batch.length < apiLimit()) break
|
||||
} else {
|
||||
const content = data.content
|
||||
total = Number(content?.total_cnt ?? total) || total
|
||||
const batch = filterCategoryContent(content?.v_item)
|
||||
items.push(...batch)
|
||||
if (batch.length < apiLimit()) break
|
||||
}
|
||||
}
|
||||
const list = items.slice(start, start + limit).map(toSummary).filter(Boolean)
|
||||
return { list, page, limit, total: total || items.length, source: 'tx' as const }
|
||||
}
|
||||
|
||||
/** 上游 filterList2 的字段映射 + song_cnt/play_cnt(trackCount/playCount 数字原值) */
|
||||
const filterCategoryContent = (raw: any[]) =>
|
||||
(raw ?? []).map(({ basic }) => {
|
||||
const id = String(basic?.tid ?? '').trim()
|
||||
const name = String(basic?.title ?? '').trim()
|
||||
if (!id || !name) return null
|
||||
const playCount = Number(basic?.play_cnt ?? 0) || 0
|
||||
const cover = String(basic?.cover?.medium_url || basic?.cover?.default_url || '')
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
img: cover,
|
||||
cover,
|
||||
author: basic?.creator?.nick ?? '',
|
||||
creator: basic?.creator?.nick ?? '',
|
||||
play_count: formatPlayCount(playCount),
|
||||
playCount,
|
||||
trackCount: Number(basic?.song_cnt ?? 0) || 0,
|
||||
total: Number(basic?.song_cnt ?? 0) || 0,
|
||||
desc: String(basic?.desc ?? '').replace(/<br>/g, '\n'),
|
||||
description: String(basic?.desc ?? '').replace(/<br>/g, '\n'),
|
||||
source: 'tx' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
/** 推荐歌单(上游 GetRecommendedPlaylists:musicu.fcg get_hot_recommend) */
|
||||
export const getRecommended = async(page = 1, limit = 20) => {
|
||||
const { statusCode, body } = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://y.qq.com/',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: {
|
||||
comm: { ct: 24 },
|
||||
recomPlaylist: {
|
||||
method: 'get_hot_recommend',
|
||||
module: 'playlist.HotRecommendServer',
|
||||
param: { async: 1, cmd: 2 },
|
||||
},
|
||||
},
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
|
||||
if (statusCode !== 200 || body?.code != 0) {
|
||||
throw new Error(`tx 推荐歌单获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
const vHot: any[] = body?.recomPlaylist?.data?.v_hot ?? []
|
||||
const list = vHot.map((item: any) => {
|
||||
const id = String(item?.content_id ?? item?.dissid ?? item?.id ?? item?.tid ?? '').trim()
|
||||
const name = String(item?.title ?? item?.dissname ?? item?.name ?? '').trim()
|
||||
if (!id || !name) return null
|
||||
const cover = String(item?.cover ?? item?.imgurl ?? '')
|
||||
const playCount = Number(item?.listen_num ?? item?.listennum ?? 0) || 0
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
cover: cover.startsWith('http://') ? cover.replace('http://', 'https://') : cover,
|
||||
trackCount: Number(item?.song_cnt ?? item?.song_count ?? item?.songnum ?? 0) || 0,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator: String(item?.username ?? item?.creator?.nick ?? '').trim(),
|
||||
description: '',
|
||||
link: playlistLink(id),
|
||||
source: 'tx' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
if (!list.length) throw new Error('no recommended playlists found')
|
||||
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return { list: list.slice(start, start + limit), page, limit, total: list.length, source: 'tx' as const }
|
||||
}
|
||||
|
||||
export const getRecommendedPlaylists = getRecommended
|
||||
|
||||
/** 上游 extractQQPlaylistID:支持现代 ryqq 路由 / 老版详情页 id、disstid 查询;拒绝非歌单链接 */
|
||||
export const extractPlaylistId = (link: string): string | null => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) return null
|
||||
|
||||
try {
|
||||
const parsed = new URL(raw)
|
||||
const pathValue = parsed.pathname.toLowerCase()
|
||||
if (pathValue.includes('playlist')) {
|
||||
for (const key of ['id', 'disstid', 'dissid']) {
|
||||
const id = normalizePlaylistId(parsed.searchParams.get(key) ?? '')
|
||||
if (id) return id
|
||||
}
|
||||
}
|
||||
const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').split('/')
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (parts[i].toLowerCase() === 'playlist' && i + 1 < parts.length) {
|
||||
const id = normalizePlaylistId(parts[i + 1])
|
||||
if (id) return id
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 非 URL(裸 id)走下方兜底
|
||||
}
|
||||
|
||||
const match = /(?:^|\/)playlist\/(\d+)(?:[/?#]|$)/.exec(raw)
|
||||
if (match) return match[1]
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizePlaylistId = (value: string): string => {
|
||||
const trimmed = String(value ?? '').trim()
|
||||
if (!trimmed || !/^\d+$/.test(trimmed)) return ''
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** 歌单链接解析(上游 ParsePlaylist):提取 disstid 后取详情;短链走 handleParseId 重定向兜底 */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) throw new Error('无效的QQ音乐歌单链接')
|
||||
// 明确是歌曲/专辑页时直接拒绝,避免把其中的数字 id 误判为歌单(上游同样拒绝)
|
||||
if (!/playlist/i.test(raw) && /(albumDetail|\/album\/|albummid=|songDetail|\/song\/|songmid=|[?&]songid=)/.test(raw)) {
|
||||
throw new Error('无效的QQ音乐歌单链接')
|
||||
}
|
||||
|
||||
let id = extractPlaylistId(raw)
|
||||
if (!id && !/\d/.test(raw)) throw new Error('无效的QQ音乐歌单链接')
|
||||
if (!id) {
|
||||
// 纯数字裸 id 或短链:复用 songList 的 ID 归一(含 302 兜底)
|
||||
try {
|
||||
id = await songList.getListId(raw)
|
||||
} catch {
|
||||
id = ''
|
||||
}
|
||||
}
|
||||
if (!id) throw new Error('无效的QQ音乐歌单链接')
|
||||
return getPlaylistDetail(id)
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
getRecommended,
|
||||
getRecommendedPlaylists,
|
||||
parsePlaylistLink,
|
||||
extractPlaylistId,
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
// QQ音乐个人歌单(上游 music-lib/qq/user_playlist.go 移植)
|
||||
// 需登录 cookie(sdk/auth/store.ts 的 getCredentials('tx').cookie,须含 uin 类字段):
|
||||
// 未登录/缺 uin 抛出清晰错误;不静默返回空列表
|
||||
// 数据源(与上游一致,结果去重):
|
||||
// 1) fcg_user_created_diss → 自建歌单
|
||||
// 2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
|
||||
// 3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
|
||||
// 说明:虚拟条目的 id 为 profile:favorites(与上游一致),歌曲需凭 cookie 走同一接口拉取
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
import type { TxCredentials } from '../auth/store.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
const FAVORITE_ID = 'profile:favorites'
|
||||
const FAVORITE_NAME = '我喜欢的歌曲'
|
||||
|
||||
/** 内联读取 cookie(与 sdk/auth/store.ts 的 TxCredentials 同源;避免动 store) */
|
||||
const readCookie = (): string => {
|
||||
const cred = getCredentials('tx') as TxCredentials | null
|
||||
return String(cred?.cookie ?? '').trim()
|
||||
}
|
||||
|
||||
/** 上游 normalizeQQUIN:从 cookie 中按已知键取 uin */
|
||||
const cookieValue = (cookie: string, key: string): string => {
|
||||
for (const part of cookie.split(';')) {
|
||||
const kv = part.trim().split(/=(.*)/s)
|
||||
if (kv.length === 2 && kv[0].trim() === key) return kv[1].trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const normalizeQQUIN = (cookie: string): string => {
|
||||
const raw = [
|
||||
'wxuin', 'uin', 'ptui_loginuin', 'luin', 'pt2gguin', 'superuin', 'p_uin', 'musicid', 'userid',
|
||||
].map(key => cookieValue(cookie, key)).find(value => value) ?? ''
|
||||
return raw.replace(/^o/, '').replace(/^0+/, '').trim()
|
||||
}
|
||||
|
||||
const playlistLink = (id: string) => `https://y.qq.com/n/ryqq/playlist/${id}`
|
||||
|
||||
const normalizeCover = (cover: string): string => {
|
||||
const value = String(cover ?? '').trim()
|
||||
if (value.startsWith('//')) return `https:${value}`
|
||||
if (value.startsWith('http://')) return value.replace('http://', 'https://')
|
||||
return value
|
||||
}
|
||||
|
||||
type PlaylistSummary = {
|
||||
id: string
|
||||
name: string
|
||||
cover: string
|
||||
trackCount: number
|
||||
playCount: number
|
||||
play_count: string
|
||||
creator: string
|
||||
description: string
|
||||
link: string
|
||||
source: 'tx'
|
||||
}
|
||||
|
||||
const makeSummary = (partial: Partial<PlaylistSummary> & { id: string, name: string }): PlaylistSummary => {
|
||||
const playCount = Number(partial.playCount ?? 0) || 0
|
||||
return {
|
||||
id: partial.id,
|
||||
name: partial.name,
|
||||
cover: normalizeCover(partial.cover ?? ''),
|
||||
trackCount: Number(partial.trackCount ?? 0) || 0,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator: partial.creator ?? '',
|
||||
description: partial.description ?? '',
|
||||
link: partial.link ?? playlistLink(partial.id),
|
||||
source: 'tx',
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 profileOrderAssetParams */
|
||||
const profileAssetUrl = (uin: string, reqtype: string, page: number, limit: number) => {
|
||||
const offset = (Math.max(1, page) - 1) * limit
|
||||
const params = new URLSearchParams({
|
||||
format: 'json',
|
||||
inCharset: 'utf8',
|
||||
outCharset: 'utf-8',
|
||||
platform: 'yqq.json',
|
||||
needNewCode: '0',
|
||||
loginUin: uin,
|
||||
hostUin: '0',
|
||||
notice: '0',
|
||||
g_tk: '5381',
|
||||
ct: '20',
|
||||
cid: '205360956',
|
||||
userid: uin,
|
||||
reqtype,
|
||||
sin: String(offset),
|
||||
ein: String(offset + limit - 1),
|
||||
})
|
||||
return `https://c.y.qq.com/fav/fcgi-bin/fcg_get_profile_order_asset.fcg?${params.toString()}`
|
||||
}
|
||||
|
||||
const fetchJson = async(url: string, cookie: string) => {
|
||||
const { statusCode, body } = await httpFetch(url, {
|
||||
headers: { 'User-Agent': UA, Referer: 'https://y.qq.com/', Cookie: cookie },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error('tx 个人歌单接口请求失败')
|
||||
return body
|
||||
}
|
||||
|
||||
/** 自建歌单(上游 fcg_user_created_diss 段) */
|
||||
const fetchCreated = async(cookie: string, uin: string, page: number, limit: number) => {
|
||||
const params = new URLSearchParams({
|
||||
hostuin: uin,
|
||||
sin: String((page - 1) * limit),
|
||||
size: String(limit),
|
||||
format: 'json',
|
||||
inCharset: 'utf8',
|
||||
outCharset: 'utf-8',
|
||||
})
|
||||
const body = await fetchJson(`https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss?${params.toString()}`, cookie)
|
||||
if (body.code != 0) throw new Error(`tx 自建歌单获取失败: ${body.message ?? ''} (code ${body.code})`)
|
||||
const list: any[] = []
|
||||
for (const item of body?.data?.disslist ?? []) {
|
||||
const dissID = Number(item?.dissid) || 0
|
||||
const tid = Number(item?.tid) || 0
|
||||
const dirID = Number(item?.dirid) || 0
|
||||
const id = dissID > 0 ? String(dissID) : tid > 0 ? String(tid) : dirID > 0 ? `profile:dir:${dirID}` : ''
|
||||
const name = String(item?.diss_name ?? item?.title ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
list.push(makeSummary({
|
||||
id,
|
||||
name,
|
||||
cover: item?.diss_cover ?? item?.cover,
|
||||
trackCount: Number(item?.song_count ?? item?.song_num ?? item?.song_cnt ?? 0) || 0,
|
||||
playCount: Number(item?.listen_num ?? item?.visitnum ?? 0) || 0,
|
||||
creator: uin,
|
||||
description: String(item?.diss_desc ?? item?.desc ?? ''),
|
||||
}))
|
||||
}
|
||||
for (const item of body?.data?.list ?? []) {
|
||||
const id = String(item?.dissid ?? '').trim()
|
||||
const name = String(item?.dissname ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
list.push(makeSummary({
|
||||
id,
|
||||
name,
|
||||
cover: item?.imgurl,
|
||||
trackCount: Number(item?.song_count ?? item?.song_num ?? 0) || 0,
|
||||
playCount: Number(item?.listennum ?? 0) || 0,
|
||||
creator: uin,
|
||||
description: String(item?.introduction ?? ''),
|
||||
}))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/** 收藏歌单(上游 fetchProfileOrderPlaylists,reqtype=3) */
|
||||
const fetchCollected = async(cookie: string, uin: string, page: number, limit: number) => {
|
||||
const body = await fetchJson(profileAssetUrl(uin, '3', page, limit), cookie)
|
||||
if (body.code != 0) return []
|
||||
const list: any[] = []
|
||||
for (const item of body?.data?.cdlist ?? []) {
|
||||
const id = Number(item?.dissid) || 0
|
||||
const name = String(item?.dissname ?? '').trim()
|
||||
if (id <= 0 || !name) continue
|
||||
list.push(makeSummary({
|
||||
id: String(id),
|
||||
name,
|
||||
cover: item?.logo,
|
||||
trackCount: Number(item?.songnum ?? 0) || 0,
|
||||
playCount: Number(item?.listennum ?? 0) || 0,
|
||||
creator: String(item?.nickname ?? item?.uin ?? uin),
|
||||
}))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/** 「我喜欢的歌曲」摘要(上游 fetchFavoriteSongsPlaylistSummary,reqtype=1 limit=1) */
|
||||
const fetchFavoriteSummary = async(cookie: string, uin: string) => {
|
||||
const body = await fetchJson(profileAssetUrl(uin, '1', 1, 1), cookie)
|
||||
if (body.code != 0) return null
|
||||
const total = Number(body?.data?.totalsong ?? 0) || 0
|
||||
if (total <= 0) return null
|
||||
const first = body?.data?.songlist?.[0]?.data
|
||||
return makeSummary({
|
||||
id: FAVORITE_ID,
|
||||
name: FAVORITE_NAME,
|
||||
cover: first?.albummid ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${first.albummid}.jpg` : '',
|
||||
trackCount: total,
|
||||
creator: uin,
|
||||
description: 'QQ音乐我喜欢的歌曲',
|
||||
link: 'https://y.qq.com/n/ryqq/profile',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人歌单列表(上游 GetUserPlaylists)
|
||||
* 未登录(无 cookie 或无 uin)时抛出清晰错误,不返回空列表
|
||||
*/
|
||||
export const getList = async(page = 1, limit = 30) => {
|
||||
const cookie = readCookie()
|
||||
if (!cookie) throw new Error('tx 个人歌单需要登录 cookie(请先登录 QQ音乐)')
|
||||
const uin = normalizeQQUIN(cookie)
|
||||
if (!uin) throw new Error('tx 个人歌单需要 cookie 中的 uin(当前 cookie 不含 uin/wxuin 等字段)')
|
||||
|
||||
const safePage = Math.max(1, Number(page) || 1)
|
||||
let safeLimit = Number(limit) || 30
|
||||
if (safeLimit <= 0) safeLimit = 30
|
||||
if (safeLimit > 100) safeLimit = 100
|
||||
|
||||
const seen = new Set<string>()
|
||||
const list: PlaylistSummary[] = []
|
||||
const add = (summary: PlaylistSummary | null) => {
|
||||
if (!summary || !summary.id || !summary.name || seen.has(summary.id)) return
|
||||
seen.add(summary.id)
|
||||
list.push(summary)
|
||||
}
|
||||
|
||||
try {
|
||||
add(await fetchFavoriteSummary(cookie, uin))
|
||||
} catch {
|
||||
// 摘要失败不阻塞列表(与上游一致)
|
||||
}
|
||||
for (const item of await fetchCreated(cookie, uin, safePage, safeLimit)) add(item)
|
||||
for (const item of await fetchCollected(cookie, uin, safePage, safeLimit)) add(item)
|
||||
|
||||
return { list, page: safePage, limit: safeLimit, total: list.length, source: 'tx' as const }
|
||||
}
|
||||
|
||||
export const getUserPlaylists = getList
|
||||
|
||||
export default { getList, getUserPlaylists, normalizeQQUIN }
|
||||
+14
-5
@@ -2,7 +2,10 @@
|
||||
// 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。
|
||||
|
||||
/** 音乐平台标识 */
|
||||
export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'xm' | 'me' | 'qt' | 'lr'
|
||||
export type Source =
|
||||
| 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
|
||||
| 'migu' | 'qian' | 'joox' | 'bili' | '5sing' | 'apple'
|
||||
| 'xm' | 'me' | 'qt' | 'lr'
|
||||
|
||||
/** 音质标识(wav 仅部分接口声明使用) */
|
||||
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
|
||||
@@ -71,6 +74,12 @@ export interface RequestObj<T> {
|
||||
cancelHttp(): void
|
||||
}
|
||||
|
||||
/** 请求句柄的宽松形态:SDK 内 promise 会在 .then() 中改写为对应结果类型 */
|
||||
export interface AnyRequestObj {
|
||||
promise: Promise<any>
|
||||
cancelHttp(): void
|
||||
}
|
||||
|
||||
/** 取流结果 */
|
||||
export interface MusicUrlResult {
|
||||
type: string
|
||||
@@ -136,10 +145,10 @@ export interface PlatformModule {
|
||||
songList?: any
|
||||
hotSearch?: HotSearchModule
|
||||
comment?: CommentModule
|
||||
getMusicUrl?(songInfo: SongInfo, type: Quality): HttpRequestObject
|
||||
getLyric?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject
|
||||
getPic?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject | Promise<any>
|
||||
getMusicInfo?(songInfo: SongInfo): HttpRequestObject | Promise<any>
|
||||
getMusicUrl?(songInfo: SongInfo, type: Quality): AnyRequestObj | Promise<any>
|
||||
getLyric?(songInfo: SongInfo, ...rest: unknown[]): AnyRequestObj
|
||||
getPic?(songInfo: SongInfo, ...rest: unknown[]): AnyRequestObj | Promise<any>
|
||||
getMusicInfo?(songInfo: SongInfo): AnyRequestObj | Promise<any>
|
||||
getMusicDetailPageUrl?(songInfo: SongInfo): string
|
||||
[key: string]: any
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
// 网易云专辑(上游 music-lib/netease/album.go + netease.go 的 fetchAlbumDetail 移植)
|
||||
// 搜索:cloudsearch pc type=10(eapi 通道,与现有 songList.search 同一通路)
|
||||
// 详情/曲目:weapi/v1/album/<id>(匿名可用;登录 cookie 可选)
|
||||
// 链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
import { eapiRequest } from './utils/index.ts'
|
||||
import { parseNeteaseLink } from './link.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
|
||||
const albumLink = (id: string) => `https://music.163.com/#/album?id=${id}`
|
||||
|
||||
/** 网易云歌曲条目 → MusicServer Song 形状(与 wy/songList.filterListDetail 对齐) */
|
||||
export const convertSong = (item: any) => {
|
||||
const types: Array<{ type: string, size: string | null }> = []
|
||||
const _types: Record<string, { size: string | null }> = {}
|
||||
const push = (type: string, sizeRaw: any) => {
|
||||
const size = typeof sizeRaw === 'number' && sizeRaw > 0 ? sizeFormate(sizeRaw) : null
|
||||
if (!size) return
|
||||
types.push({ type, size })
|
||||
_types[type] = { size }
|
||||
}
|
||||
push('128k', item?.l?.size)
|
||||
push('320k', item?.h?.size)
|
||||
push('flac', item?.sq?.size)
|
||||
push('flac24bit', item?.hr?.size)
|
||||
|
||||
return {
|
||||
singer: formatSingerName(item?.ar, 'name'),
|
||||
name: item?.name ?? '',
|
||||
albumName: item?.al?.name ?? '',
|
||||
albumId: item?.al?.id ?? item?.album?.id,
|
||||
source: 'wy' as const,
|
||||
interval: formatPlayTime((item?.dt ?? 0) / 1000),
|
||||
songmid: item?.id,
|
||||
img: item?.al?.picUrl ?? item?.album?.picUrl ?? '',
|
||||
lrc: null,
|
||||
otherSource: null,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑详情 + 曲目(上游 fetchAlbumDetail:weapi/v1/album/<id>) */
|
||||
export const fetchAlbumDetail = async(albumId: string) => {
|
||||
const albumID = String(albumId ?? '').trim()
|
||||
if (!albumID) throw new Error('wy 专辑 id 为空')
|
||||
const cookie = getWyCookie()
|
||||
const { statusCode, body } = await httpFetch(`https://music.163.com/weapi/v1/album/${encodeURIComponent(albumID)}`, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi({ csrf_token: '' }),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.code != 200) {
|
||||
throw new Error(`wy 专辑详情获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
|
||||
const album = body.album ?? {}
|
||||
const artistName = album.artist?.name
|
||||
|| (album.artists ?? []).map((a: any) => a.name).filter(Boolean).join('、')
|
||||
const songs = (body.songs ?? []).map(convertSong)
|
||||
return {
|
||||
source: 'wy' as const,
|
||||
info: {
|
||||
name: album.name ?? '',
|
||||
img: album.picUrl ?? '',
|
||||
desc: album.description || album.briefDesc || '',
|
||||
author: artistName ?? '',
|
||||
albumId: String(album.id ?? albumID),
|
||||
publishTime: album.publishTime ?? null,
|
||||
company: album.company ?? '',
|
||||
},
|
||||
list: songs,
|
||||
total: Number(album.size ?? 0) || songs.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑详情(分页切片,对齐 spec { source, info, list, page, limit, total }) */
|
||||
export const getAlbumDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const detail = await fetchAlbumDetail(id)
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return {
|
||||
source: 'wy' as const,
|
||||
info: detail.info,
|
||||
list: detail.list.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: detail.total || detail.list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 专辑曲目全量(上游 GetAlbumSongs) */
|
||||
export const getAlbumSongs = async(id: string) => (await fetchAlbumDetail(id)).list
|
||||
|
||||
/** 专辑搜索(上游 SearchAlbum:cloudSearch type=10) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const req = eapiRequest('/api/cloudsearch/pc', {
|
||||
s: String(keyword ?? ''),
|
||||
type: 10,
|
||||
limit,
|
||||
total: page === 1,
|
||||
offset: limit * (page - 1),
|
||||
})
|
||||
const { statusCode, body } = await req.promise
|
||||
if (statusCode !== 200 || body?.code != 200) throw new Error(`wy 专辑搜索失败 (code ${body?.code ?? statusCode})`)
|
||||
|
||||
const albums: any[] = body?.result?.albums ?? []
|
||||
const list = albums.map(item => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const artistName = item?.artist?.name
|
||||
|| (item?.artists ?? []).map((a: any) => a?.name).filter(Boolean).join('、')
|
||||
return {
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.picUrl ?? '',
|
||||
trackCount: Number(item?.size ?? 0) || 0,
|
||||
creator: artistName ?? '',
|
||||
description: item?.description || item?.briefDesc || '',
|
||||
link: albumLink(id),
|
||||
type: 'album',
|
||||
company: item?.company ?? '',
|
||||
publishTime: item?.publishTime ?? null,
|
||||
source: 'wy' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
const total = Number(body?.result?.albumCount ?? list.length) || list.length
|
||||
return { list, page, limit, total, source: 'wy' as const }
|
||||
}
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
// 裸数字 id 兜底(上游 ParseAlbum 只接受链接)
|
||||
if (/^\d+$/.test(raw)) return getAlbumDetail(raw)
|
||||
const { kind, id } = parseNeteaseLink(raw)
|
||||
if (kind !== 'album') throw new Error('无效的网易云专辑链接')
|
||||
return getAlbumDetail(id)
|
||||
}
|
||||
|
||||
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink, fetchAlbumDetail }
|
||||
@@ -7,6 +7,10 @@ import songList from './songList.ts'
|
||||
import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import userPlaylist from './userPlaylist.ts'
|
||||
|
||||
const wy = {
|
||||
leaderboard,
|
||||
@@ -15,6 +19,10 @@ const wy = {
|
||||
hotSearch,
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
playlist,
|
||||
parse,
|
||||
userPlaylist,
|
||||
getMusicUrl(songInfo, type) {
|
||||
return wyApi.getMusicUrl(songInfo, type)
|
||||
},
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// 网易云链接解析(上游 music-lib/netease/netease.go 的 parseNeteaseLink 1:1 移植)
|
||||
// 支持:
|
||||
// 歌曲 https://music.163.com/#/song?id=29732995 /song?id= / /song/29732995
|
||||
// 专辑 https://music.163.com/#/album?id=32311 /album?id= / /album/32311
|
||||
// 歌单 https://music.163.com/#/playlist?id=7507256008 /playlist?id= / /playlist/<id>/<n>/...
|
||||
// 裸数字 id 由各模块自行兜底(上游 Parse 不接受裸 id)
|
||||
|
||||
export type NeteaseLinkKind = 'song' | 'album' | 'playlist'
|
||||
|
||||
const isDigits = (value: string): boolean => value !== '' && /^\d+$/.test(value)
|
||||
|
||||
const BASE = 'https://music.163.com'
|
||||
|
||||
/** 上游 parseNeteaseLinkCandidate(JS 需给相对候选补 base;Go 的 url.Parse 直接接受相对路径) */
|
||||
const parseCandidate = (candidate: string): { kind: NeteaseLinkKind | '', id: string } => {
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(candidate, BASE)
|
||||
} catch {
|
||||
return { kind: '', id: '' }
|
||||
}
|
||||
|
||||
let kind: NeteaseLinkKind | '' = ''
|
||||
const segments = parsed.pathname.toLowerCase().split('/').filter(Boolean)
|
||||
for (const segment of segments) {
|
||||
if (segment === 'song' || segment === 'album' || segment === 'playlist') kind = segment
|
||||
}
|
||||
|
||||
let id = parsed.searchParams.get('id') ?? ''
|
||||
if (!isDigits(id)) id = ''
|
||||
|
||||
if (!kind && segments.length >= 2) {
|
||||
const last = segments[segments.length - 1]!
|
||||
const prev = segments[segments.length - 2]!
|
||||
if (isDigits(last) && (prev === 'song' || prev === 'album' || prev === 'playlist')) {
|
||||
kind = prev as NeteaseLinkKind
|
||||
id = last
|
||||
}
|
||||
}
|
||||
|
||||
if (!kind || !id) return { kind: '', id: '' }
|
||||
return { kind, id }
|
||||
}
|
||||
|
||||
/** 上游 parseNeteaseLink:依次尝试原链接、path?query、hash 片段 */
|
||||
export const parseNeteaseLink = (link: string): { kind: NeteaseLinkKind, id: string } => {
|
||||
const raw = String(link ?? '').trim()
|
||||
const candidates: string[] = [raw]
|
||||
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return new URL(raw, BASE)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
if (parsed) {
|
||||
if (parsed.pathname && parsed.pathname !== '/') {
|
||||
candidates.push(parsed.pathname + parsed.search)
|
||||
}
|
||||
const fragment = parsed.hash.replace(/^#!?/, '').trim()
|
||||
if (fragment) candidates.push(fragment)
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const { kind, id } = parseCandidate(candidate)
|
||||
if (kind && id) return { kind, id }
|
||||
}
|
||||
throw new Error('无效的网易云链接')
|
||||
}
|
||||
|
||||
/** 裸 id 兜底:非 URL 且全数字时视为对应类型的 id */
|
||||
export const bareId = (link: string): string => {
|
||||
const raw = String(link ?? '').trim()
|
||||
return /^\d+$/.test(raw) ? raw : ''
|
||||
}
|
||||
|
||||
export default { parseNeteaseLink, bareId }
|
||||
@@ -0,0 +1,80 @@
|
||||
// 网易云链接解析(上游 music-lib/netease/song.go 的 Parse 移植)
|
||||
// 歌曲链接形态:
|
||||
// https://music.163.com/#/song?id=29732995
|
||||
// https://music.163.com/song?id=29732995
|
||||
// https://music.163.com/song/29732995
|
||||
// 裸数字 id 也接受(补充便利;上游仅接受链接)
|
||||
// 直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
|
||||
// 歌单/专辑链接抛清晰错误,提示使用对应 parsePlaylist/parseAlbum。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
import wyApi from '../apis/wy.ts'
|
||||
import { convertSong, parseAlbumLink } from './album.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
import { parseNeteaseLink } from './link.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
|
||||
/** 上游 fetchSongsBatch 的单曲版本(weapi/v3/song/detail) */
|
||||
const fetchSongById = async(songId: string) => {
|
||||
const cookie = getWyCookie()
|
||||
const { statusCode, body } = await httpFetch('https://music.163.com/weapi/v3/song/detail', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi({
|
||||
c: `[{"id":${songId}}]`,
|
||||
ids: `[${songId}]`,
|
||||
}),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.code != 200) {
|
||||
throw new Error(`wy 歌曲详情获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
const item = body?.songs?.[0]
|
||||
if (!item?.id) throw new Error('netease song not found')
|
||||
return convertSong(item)
|
||||
}
|
||||
|
||||
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) throw new Error('无效的网易云歌曲链接')
|
||||
|
||||
let songId = ''
|
||||
if (/^\d+$/.test(raw)) {
|
||||
songId = raw
|
||||
} else {
|
||||
const parsed = (() => {
|
||||
try {
|
||||
return parseNeteaseLink(raw)
|
||||
} catch {
|
||||
throw new Error('无效的网易云歌曲链接')
|
||||
}
|
||||
})()
|
||||
if (parsed.kind === 'playlist') throw new Error('检测到网易云歌单链接,请使用 parsePlaylist')
|
||||
if (parsed.kind === 'album') throw new Error('检测到网易云专辑链接,请使用 parseAlbum')
|
||||
if (parsed.kind !== 'song') throw new Error('无效的网易云歌曲链接')
|
||||
songId = parsed.id
|
||||
}
|
||||
|
||||
const song: any = await fetchSongById(songId)
|
||||
try {
|
||||
const hit = await wyApi.getMusicUrl(song, '320k').promise
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
} catch {
|
||||
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,168 @@
|
||||
// 网易云歌单(上游 music-lib/netease/playlist.go 移植)
|
||||
// 搜索/分类:复用现有 wy/songList(search=cloudsearch type1000、getTag=catalogue、getList=playlist/list)
|
||||
// 推荐:weapi/personalized/playlist(上游 GetRecommendedPlaylists,匿名可用)
|
||||
// 详情/曲目:复用 songList.getListDetail(linuxapi forward),按 page/limit 切片
|
||||
// 链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id(上游 parseNeteaseLink)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import { getWyCookie } from '../auth/store.ts'
|
||||
import songList from './songList.ts'
|
||||
import { parseNeteaseLink } from './link.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
// catalogue/list 接口固定页大小(songList.limit_list = 30,运行时读取以避免模块环上的 TDZ)
|
||||
const apiLimit = () => Number(songList.limit_list) || 30
|
||||
|
||||
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
|
||||
|
||||
/** 歌单摘要:兼容既有 songList 字段并补 spec 形状(id/name/cover/trackCount/creator/...) */
|
||||
const toSummary = (item: any, categoryId?: string) => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const trackCount = Number(item?.trackCount ?? item?.total ?? 0) || 0
|
||||
const playCount = Number(item?.playCount ?? 0) || 0
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.cover ?? item?.img ?? '',
|
||||
trackCount,
|
||||
playCount,
|
||||
play_count: item?.play_count ?? formatPlayCount(playCount),
|
||||
creator: item?.creator ?? item?.author ?? '',
|
||||
description: item?.description ?? item?.desc ?? '',
|
||||
link: playlistLink(id),
|
||||
source: 'wy' as const,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search) */
|
||||
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
|
||||
const result = await songList.search(String(keyword ?? ''), page, limit)
|
||||
const list = (result?.list ?? []).map(item => toSummary(item)).filter(Boolean)
|
||||
return { list, page, limit, total: result?.total ?? list.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
/** 歌单详情(上游 fetchPlaylistDetail;复用 songList.getListDetail,按 page/limit 切片) */
|
||||
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
|
||||
const detail = await songList.getListDetail(String(id ?? ''), 1)
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return {
|
||||
source: 'wy' as const,
|
||||
info: detail.info,
|
||||
list: detail.list.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: detail.total ?? detail.list.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** 歌单曲目全量(上游 GetPlaylistSongs) */
|
||||
export const getPlaylistSongs = async(id: string) => (await songList.getListDetail(String(id ?? ''), 1)).list
|
||||
|
||||
/** 歌单分类(上游 GetPlaylistCategories;复用 songList.getTag/getHotTag 并摊平) */
|
||||
export const getCategories = async() => {
|
||||
const [groups, hotTags] = await Promise.all([
|
||||
songList.getTag().catch(() => []),
|
||||
songList.getHotTag().catch(() => []),
|
||||
])
|
||||
const hotSet = new Set((hotTags ?? []).map((item: any) => String(item.id)))
|
||||
const list: any[] = [{ id: '', name: '全部', group: '全部' }]
|
||||
const seen = new Set<string>([''])
|
||||
for (const group of groups ?? []) {
|
||||
for (const item of group?.list ?? []) {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
const name = String(item?.name ?? '').trim()
|
||||
if (!id || !name || seen.has(id)) continue
|
||||
seen.add(id)
|
||||
list.push({ id, name, group: group.name ?? '', hot: hotSet.has(id) })
|
||||
}
|
||||
}
|
||||
return { source: 'wy' as const, list }
|
||||
}
|
||||
|
||||
/** 分类歌单(上游 GetCategoryPlaylists;接口每页 30 条,按入参 limit 重新切片) */
|
||||
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
|
||||
const cat = String(categoryId ?? '').trim() || '全部'
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
const apiPages = Math.max(1, Math.ceil((start + limit) / apiLimit()))
|
||||
const items: any[] = []
|
||||
let total = 0
|
||||
for (let apiPage = 1; apiPage <= apiPages; apiPage++) {
|
||||
const result = await songList.getList('hot', cat, apiPage)
|
||||
total = Number(result?.total ?? total) || total
|
||||
const batch = result?.list ?? []
|
||||
items.push(...batch)
|
||||
if (batch.length < apiLimit()) break
|
||||
}
|
||||
const list = items.slice(start, start + limit).map(item => toSummary(item, cat)).filter(Boolean)
|
||||
return { list, page, limit, total: total || items.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
/** 推荐歌单(上游 GetRecommendedPlaylists:weapi/personalized/playlist) */
|
||||
export const getRecommended = async(page = 1, limit = 20) => {
|
||||
const cookie = getWyCookie()
|
||||
const { statusCode, body } = await httpFetch('https://music.163.com/weapi/personalized/playlist', {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
form: weapi({ limit: 30, total: true, n: 1000 }),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || body?.code != 200) {
|
||||
throw new Error(`wy 推荐歌单获取失败 (code ${body?.code ?? statusCode})`)
|
||||
}
|
||||
|
||||
const rawList: any[] = body?.result ?? []
|
||||
const list = rawList.map(item => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const playCount = Number(item?.playCount ?? 0) || 0
|
||||
return {
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.picUrl ?? '',
|
||||
trackCount: Number(item?.trackCount ?? 0) || 0,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator: item?.copywriter || '网易云推荐',
|
||||
description: item?.copywriter ?? '',
|
||||
link: playlistLink(id),
|
||||
alg: item?.alg ?? '',
|
||||
source: 'wy' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
if (!list.length) throw new Error('no recommended playlists found')
|
||||
|
||||
const start = Math.max(0, (page - 1) * limit)
|
||||
return { list: list.slice(start, start + limit), page, limit, total: list.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
export const getRecommendedPlaylists = getRecommended
|
||||
|
||||
/** 歌单链接解析(上游 ParsePlaylist) */
|
||||
export const parsePlaylistLink = async(link: string) => {
|
||||
const raw = String(link ?? '').trim()
|
||||
if (!raw) throw new Error('无效的网易云歌单链接')
|
||||
if (/^\d+$/.test(raw)) return getPlaylistDetail(raw)
|
||||
const { kind, id } = parseNeteaseLink(raw)
|
||||
if (kind !== 'playlist') throw new Error('无效的网易云歌单链接')
|
||||
return getPlaylistDetail(id)
|
||||
}
|
||||
|
||||
export default {
|
||||
searchPlaylist,
|
||||
getPlaylistDetail,
|
||||
getPlaylistSongs,
|
||||
getCategories,
|
||||
getCategoryPlaylists,
|
||||
getRecommended,
|
||||
getRecommendedPlaylists,
|
||||
parsePlaylistLink,
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// 网易云个人歌单(上游 music-lib/netease/user_playlist.go 移植)
|
||||
// 需登录 cookie MUSIC_U(sdk/auth/store.ts 的 getCredentials('wy').cookie / musicU):
|
||||
// 未登录抛出清晰错误,不返回空列表
|
||||
// 1) weapi/nuser/account/get → 当前用户 userId
|
||||
// 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { weapi } from './utils/crypto.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import { getCredentials, getWyCookie } from '../auth/store.ts'
|
||||
import type { WyCredentials } from '../auth/store.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
|
||||
|
||||
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
|
||||
|
||||
const readCookie = (): string => {
|
||||
const cred = getCredentials('wy') as WyCredentials | null
|
||||
const musicU = String(cred?.musicU ?? '').trim()
|
||||
if (musicU) return `MUSIC_U=${musicU}`
|
||||
return String(cred?.cookie ?? getWyCookie()).trim()
|
||||
}
|
||||
|
||||
const weapiPost = async(url: string, data: object, cookie: string) => {
|
||||
const { statusCode, body } = await httpFetch(url, {
|
||||
method: 'post',
|
||||
headers: {
|
||||
'User-Agent': UA,
|
||||
Referer: 'https://music.163.com/',
|
||||
origin: 'https://music.163.com',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Cookie: cookie,
|
||||
},
|
||||
form: weapi(data),
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error('wy 个人歌单接口请求失败')
|
||||
return body
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人歌单列表(上游 GetUserPlaylists)
|
||||
* 未登录(无 cookie/MUSIC_U)时抛出清晰错误,不返回空列表
|
||||
*/
|
||||
export const getList = async(page = 1, limit = 30) => {
|
||||
const cookie = readCookie()
|
||||
if (!cookie) throw new Error('wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云')
|
||||
|
||||
const safePage = Math.max(1, Number(page) || 1)
|
||||
let safeLimit = Number(limit) || 30
|
||||
if (safeLimit <= 0) safeLimit = 30
|
||||
if (safeLimit > 100) safeLimit = 100
|
||||
|
||||
const account = await weapiPost('https://music.163.com/weapi/nuser/account/get', { csrf_token: '' }, cookie)
|
||||
if (account.code !== 200 || !account?.profile?.userId) {
|
||||
throw new Error(`wy 登录态无效或已过期 (code ${account?.code ?? 'unknown'})`)
|
||||
}
|
||||
const uid = account.profile.userId
|
||||
const nickname = account.profile.nickname ?? ''
|
||||
|
||||
const body = await weapiPost('https://music.163.com/weapi/user/playlist', {
|
||||
uid,
|
||||
limit: safeLimit,
|
||||
offset: (safePage - 1) * safeLimit,
|
||||
includeVideo: true,
|
||||
csrf_token: '',
|
||||
}, cookie)
|
||||
if (body.code !== 200) throw new Error(`wy 个人歌单获取失败 (code ${body.code})`)
|
||||
|
||||
const list = (body.playlist ?? []).map(item => {
|
||||
const id = String(item?.id ?? '').trim()
|
||||
if (!id) return null
|
||||
const trackCount = Number(item?.trackCount ?? 0) || 0
|
||||
const playCount = Number(item?.playCount ?? 0) || 0
|
||||
const creator = String(item?.creator?.nickname ?? '').trim() || nickname
|
||||
return {
|
||||
id,
|
||||
name: item?.name ?? '',
|
||||
cover: item?.coverImgUrl ?? '',
|
||||
trackCount,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator,
|
||||
description: item?.description ?? '',
|
||||
subscribed: Boolean(item?.subscribed),
|
||||
link: playlistLink(id),
|
||||
source: 'wy' as const,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
return { list, page: safePage, limit: safeLimit, total: list.length, source: 'wy' as const }
|
||||
}
|
||||
|
||||
export const getUserPlaylists = getList
|
||||
|
||||
export default { getList, getUserPlaylists }
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
// 汽水音乐外部签名服务 —— 在 Linux 上用 Wine 跑官方 bdms.node,对外只提供签名
|
||||
//
|
||||
// 为什么需要它:bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node/Electron 加载。
|
||||
// 把「加载官方模块」这件事单独关进一个容器(Wine + Windows Node),主服务就能跑在任何平台,
|
||||
// 通过 HTTP 拿 X-Helios / X-Medusa —— 契约与 sodahub-org/libmssdk、libresoda、
|
||||
// Meting-API 的远程签名服务互通(三者 JSON 可互换)。
|
||||
//
|
||||
// 路由:
|
||||
// GET /healthz → { ok, service, version, bdmsAvailable, deviceId(脱敏), auth, platform }
|
||||
// GET /config → 需鉴权:设备身份(调用方据此对齐 URL 里的 device_id/iid)
|
||||
// POST /sign → { url, method?, headers, body?, device_id? } → { ok, headers:{X-Helios,X-Medusa}, device_id, elapsed_ms }
|
||||
// POST /selftest → 用样例请求自检(不联网)
|
||||
//
|
||||
// 鉴权:QS_SIGNER_TOKEN(Authorization: Bearer <token>)。未设 token 时除非
|
||||
// QS_SIGNER_ALLOW_ANONYMOUS=1,否则拒绝启动 —— 签名能力等价于账号取流能力,别裸奔。
|
||||
// 环境变量:QS_SIGNER_BIND(127.0.0.1) / QS_SIGNER_PORT(8899) / QS_SIGNER_TOKEN /
|
||||
// QS_SIGNER_DEVICE_ID / QS_SIGNER_BDMS_MODULE(默认 ./vendor/bdms.node) / QS_SIGNER_MAX_BODY
|
||||
// 日志只记请求形状与耗时:Cookie、X-Helios、X-Medusa 一律不落盘。
|
||||
'use strict'
|
||||
|
||||
const http = require('node:http')
|
||||
const crypto = require('node:crypto')
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
const SERVICE = 'qs-signer'
|
||||
const BIND = process.env.QS_SIGNER_BIND || '127.0.0.1'
|
||||
const PORT = Number(process.env.QS_SIGNER_PORT) || 8899
|
||||
const TOKEN = process.env.QS_SIGNER_TOKEN || ''
|
||||
const ALLOW_ANONYMOUS = process.env.QS_SIGNER_ALLOW_ANONYMOUS === '1'
|
||||
const DEFAULT_DEVICE_ID = process.env.QS_SIGNER_DEVICE_ID || ''
|
||||
const MAX_BODY = Number(process.env.QS_SIGNER_MAX_BODY) || 256 * 1024
|
||||
const MODULE_PATH = path.resolve(process.env.QS_SIGNER_BDMS_MODULE || path.join(__dirname, 'vendor', 'bdms.node'))
|
||||
|
||||
if (!TOKEN && !ALLOW_ANONYMOUS) {
|
||||
console.error('[qs-signer] 拒绝启动:未设置 QS_SIGNER_TOKEN(本机调试可显式 QS_SIGNER_ALLOW_ANONYMOUS=1)')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const maskDevice = (id) => {
|
||||
const value = String(id || '')
|
||||
return value.length <= 6 ? value : `${value.slice(0, 3)}***${value.slice(-3)}`
|
||||
}
|
||||
|
||||
// —— 官方模块加载:一次 require,按设备 init(换设备要重新 init)——
|
||||
let bdms = null
|
||||
let loadError = ''
|
||||
let initedDevice = ''
|
||||
const loadBdms = () => {
|
||||
if (bdms) return bdms
|
||||
if (loadError) return null
|
||||
try {
|
||||
if (!fs.existsSync(MODULE_PATH)) throw new Error(`找不到 ${MODULE_PATH}`)
|
||||
const mod = require(MODULE_PATH)
|
||||
if (typeof mod.init !== 'function' || typeof mod.generateHttpSignatureHeaders !== 'function') {
|
||||
throw new Error('导出不符合预期(需要 init / generateHttpSignatureHeaders)')
|
||||
}
|
||||
bdms = mod
|
||||
console.log(`[qs-signer] bdms 模块已加载: ${MODULE_PATH}`)
|
||||
return bdms
|
||||
} catch (err) {
|
||||
loadError = String((err && err.message) || err)
|
||||
console.error(`[qs-signer] bdms 模块加载失败: ${loadError}(/sign 将返回 503,/healthz 仍可用)`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const deviceIdFromRequest = (url, headers, explicit) => {
|
||||
if (explicit) return String(explicit).trim()
|
||||
try {
|
||||
const parsed = new URL(String(url))
|
||||
for (const key of ['device_id', 'did', 'fp']) {
|
||||
const value = parsed.searchParams.get(key)
|
||||
if (value) return value.trim()
|
||||
}
|
||||
} catch {
|
||||
// URL 不合法就继续往下找
|
||||
}
|
||||
for (const name of ['x-device-id', 'device-id', 'device_id']) {
|
||||
if (headers && headers[name]) return String(headers[name]).trim()
|
||||
}
|
||||
return DEFAULT_DEVICE_ID
|
||||
}
|
||||
|
||||
const ensureInit = (deviceId) => {
|
||||
const mod = loadBdms()
|
||||
if (!mod) throw new Error(loadError || 'bdms 模块不可用')
|
||||
const target = String(deviceId || DEFAULT_DEVICE_ID || '').trim()
|
||||
if (!target) throw new Error('缺少 device_id:请求里带 device_id(或 URL 查询参数 device_id/did/fp),或设置 QS_SIGNER_DEVICE_ID')
|
||||
if (initedDevice !== target) {
|
||||
mod.init({ deviceId: target })
|
||||
initedDevice = target
|
||||
console.log(`[qs-signer] 已绑定设备 ${maskDevice(target)}`)
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
const flattenHeaders = (headers) => {
|
||||
const lines = []
|
||||
for (const [name, value] of Object.entries(headers || {})) {
|
||||
if (!name || value === undefined || value === null) continue
|
||||
const items = Array.isArray(value) ? value : [value]
|
||||
for (const item of items) lines.push(`${name}\r\n${item}`)
|
||||
}
|
||||
return lines.join('\r\n')
|
||||
}
|
||||
|
||||
const parseSignaturePairs = (raw) => {
|
||||
const fields = String(raw || '').split('\r\n').filter((t) => t.trim())
|
||||
const signed = {}
|
||||
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
|
||||
return signed
|
||||
}
|
||||
|
||||
/** 签名一次请求:返回需追加/覆盖的请求头(含服务端补的 x-ss-stub) */
|
||||
const signRequest = ({ url, headers = {}, body, deviceId }) => {
|
||||
if (!url) throw new Error('url 不能为空')
|
||||
const target = ensureInit(deviceIdFromRequest(url, headers, deviceId))
|
||||
const outgoing = { ...headers }
|
||||
// 有 body 却没给 x-ss-stub 时补上(它是签名输入,也要随请求发出),大小写保持官方写法
|
||||
if (body && !Object.keys(outgoing).some((k) => k.toLowerCase() === 'x-ss-stub')) {
|
||||
outgoing['x-ss-stub'] = crypto.createHash('md5').update(String(body)).digest('hex').toUpperCase()
|
||||
}
|
||||
const signed = parseSignaturePairs(target.generateHttpSignatureHeaders(String(url), flattenHeaders(outgoing)))
|
||||
if (!signed['X-Helios'] && !signed['x-helios']) throw new Error('签名结果缺少 X-Helios')
|
||||
return { signed: { ...signed, ...(body ? { 'x-ss-stub': outgoing['x-ss-stub'] } : {}) }, deviceId: initedDevice }
|
||||
}
|
||||
|
||||
const json = (res, status, payload) => {
|
||||
const text = JSON.stringify(payload)
|
||||
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(text) })
|
||||
res.end(text)
|
||||
}
|
||||
|
||||
const readBody = (req) => new Promise((resolve, reject) => {
|
||||
let size = 0
|
||||
const chunks = []
|
||||
req.on('data', (chunk) => {
|
||||
size += chunk.length
|
||||
if (size > MAX_BODY) {
|
||||
reject(Object.assign(new Error('请求体过大'), { status: 413 }))
|
||||
req.destroy()
|
||||
return
|
||||
}
|
||||
chunks.push(chunk)
|
||||
})
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
||||
req.on('error', reject)
|
||||
})
|
||||
|
||||
const authorized = (req) => {
|
||||
if (!TOKEN) return true
|
||||
const header = String(req.headers.authorization || '')
|
||||
const bearer = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : ''
|
||||
const alt = String(req.headers['x-signer-token'] || '')
|
||||
return bearer === TOKEN || alt === TOKEN
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const started = Date.now()
|
||||
const url = String(req.url || '')
|
||||
const pathname = url.split('?')[0]
|
||||
|
||||
const log = (status) => console.log(`[qs-signer] ${req.method} ${pathname} → ${status} (${Date.now() - started}ms)`)
|
||||
|
||||
try {
|
||||
if (pathname === '/healthz') {
|
||||
log(200)
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
service: SERVICE,
|
||||
version: VERSION,
|
||||
bdmsAvailable: Boolean(bdms) || (!loadError && fs.existsSync(MODULE_PATH)),
|
||||
deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID),
|
||||
auth: Boolean(TOKEN),
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
node: process.version,
|
||||
})
|
||||
}
|
||||
|
||||
if (!authorized(req)) {
|
||||
log(401)
|
||||
return json(res, 401, { ok: false, error: 'unauthorized', code: 'unauthorized' })
|
||||
}
|
||||
|
||||
if (pathname === '/config') {
|
||||
log(200)
|
||||
return json(res, 200, {
|
||||
ok: true,
|
||||
service: SERVICE,
|
||||
version: VERSION,
|
||||
bdmsModule: MODULE_PATH,
|
||||
bdmsLoaded: Boolean(bdms),
|
||||
loadError,
|
||||
deviceId: maskDevice(initedDevice || DEFAULT_DEVICE_ID),
|
||||
identity: { device_id: initedDevice || DEFAULT_DEVICE_ID, source: initedDevice ? 'request' : (DEFAULT_DEVICE_ID ? 'env' : 'unset') },
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/selftest') {
|
||||
try {
|
||||
const sampleUrl = 'https://api.qishui.com/luna/pc/track_v2?aid=386088&device_id=1&iid=2'
|
||||
const { signed } = signRequest({ url: sampleUrl, headers: { 'user-agent': 'LunaPC/selftest' }, deviceId: DEFAULT_DEVICE_ID || '1' })
|
||||
log(200)
|
||||
return json(res, 200, { ok: true, headers: Object.keys(signed), lengths: Object.fromEntries(Object.entries(signed).map(([k, v]) => [k, String(v).length])) })
|
||||
} catch (err) {
|
||||
log(503)
|
||||
return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' })
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === '/sign' && req.method === 'POST') {
|
||||
const raw = await readBody(req)
|
||||
let payload
|
||||
try {
|
||||
payload = JSON.parse(raw || '{}')
|
||||
} catch {
|
||||
log(400)
|
||||
return json(res, 400, { ok: false, error: 'invalid JSON', code: 'bad_request' })
|
||||
}
|
||||
const { url: target, headers = {}, body = '', device_id: deviceId } = payload
|
||||
try {
|
||||
const { signed } = signRequest({ url: target, headers, body, deviceId })
|
||||
log(200)
|
||||
// headers 嵌套给标准调用方;顶层扁平给 Meting 风格调用方
|
||||
return json(res, 200, { ok: true, headers: signed, ...signed, device_id: initedDevice, elapsed_ms: Date.now() - started })
|
||||
} catch (err) {
|
||||
log(503)
|
||||
return json(res, 503, { ok: false, error: String((err && err.message) || err), code: 'signer_error' })
|
||||
}
|
||||
}
|
||||
|
||||
log(404)
|
||||
return json(res, 404, { ok: false, error: 'not found' })
|
||||
} catch (err) {
|
||||
const status = err && err.status ? err.status : 500
|
||||
log(status)
|
||||
return json(res, status, { ok: false, error: String((err && err.message) || err) })
|
||||
}
|
||||
})
|
||||
|
||||
server.listen(PORT, BIND, () => {
|
||||
console.log(`[qs-signer] 监听 http://${BIND}:${PORT}(鉴权: ${TOKEN ? 'Bearer token' : '匿名(仅调试)'})`)
|
||||
loadBdms()
|
||||
})
|
||||
@@ -0,0 +1,120 @@
|
||||
// 咪咕加密流票据 —— /url/migu 命中 Z3D/I3D 加密候选时登记,/stream/migu/:id 凭票解密
|
||||
// (统一入口为 /stream/:source/:id,source=migu 时 id 即此处返回的票据)。
|
||||
// 不把上游 URL 塞进播放地址(Fastify path 参数限长,且 URL 含时效签名与中文路径),
|
||||
// 票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { get as httpGet } from 'node:http'
|
||||
import { get as httpsGet } from 'node:https'
|
||||
|
||||
interface MiguStreamTicket {
|
||||
url: string
|
||||
fileKey: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const TTL_MS = 10 * 60 * 1000
|
||||
const STREAM_CACHE_LIMIT = 200
|
||||
const tickets = new Map<string, MiguStreamTicket>()
|
||||
|
||||
const prune = () => {
|
||||
const now = Date.now()
|
||||
for (const [id, ticket] of tickets) {
|
||||
if (now - ticket.createdAt > TTL_MS) tickets.delete(id)
|
||||
}
|
||||
// 超出容量时按插入顺序淘汰最旧票据
|
||||
while (tickets.size > STREAM_CACHE_LIMIT) {
|
||||
const oldest = tickets.keys().next().value
|
||||
if (oldest == null) break
|
||||
tickets.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登记一条加密流,返回可直接放入 /stream/migu/:id 的短票据 id */
|
||||
export const registerMiguStream = (url: string, fileKey: string): string => {
|
||||
prune()
|
||||
const id = randomUUID().replace(/-/g, '')
|
||||
tickets.set(id, { url, fileKey, createdAt: Date.now() })
|
||||
return id
|
||||
}
|
||||
|
||||
/** 取出票据(过期返回 null) */
|
||||
export const takeMiguStream = (id: string): MiguStreamTicket | null => {
|
||||
const ticket = tickets.get(id)
|
||||
if (!ticket) return null
|
||||
if (Date.now() - ticket.createdAt > TTL_MS) {
|
||||
tickets.delete(id)
|
||||
return null
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
const MIGU_STREAM_UA = 'Android_migu/7.41.13 okhttp/3.12.13'
|
||||
|
||||
/** 按扩展名推断明文的 content-type */
|
||||
export const miguStreamContentType = (url: string): string => {
|
||||
if (url.includes('.flac')) return 'audio/flac'
|
||||
if (url.includes('.wav')) return 'audio/wav'
|
||||
if (url.includes('.m4a')) return 'audio/mp4'
|
||||
return 'audio/mpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式下载密流并逐块解密后写入响应。
|
||||
* 咪咕 Z3D 的解密是逐字节 `plain[i] = cipher[i] - streamKey[i % 32]`,
|
||||
* 因此可按块处理(保持全局偏移),无需把整段音频(可达上百 MB,超过 httpFetch 的缓冲上限)读进内存。
|
||||
* @param decryptChunk 处理单块(参数:密文块、块在流中的起始偏移),返回明文块
|
||||
*/
|
||||
export const pipeDecryptedMiguStream = (
|
||||
url: string,
|
||||
transform: (chunk: Buffer, offset: number) => Buffer,
|
||||
onChunk: (plain: Buffer, offset: number) => void,
|
||||
redirectsLeft = 3,
|
||||
): Promise<void> => new Promise((resolve, reject) => {
|
||||
const get = url.startsWith('https:') ? httpsGet : httpGet
|
||||
// 只 settle 一次:异常后不再 resolve/reject,避免二次结算被忽略或覆盖真实错误
|
||||
let settled = false
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
fn()
|
||||
}
|
||||
const req = get(url, {
|
||||
headers: { 'User-Agent': MIGU_STREAM_UA, Referer: 'https://music.migu.cn/' },
|
||||
}, (res: any) => {
|
||||
const status = res.statusCode ?? 0
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
if (redirectsLeft <= 0) return settle(() => reject(new Error('重定向次数过多')))
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
return pipeDecryptedMiguStream(next, transform, onChunk, redirectsLeft - 1).then(
|
||||
() => settle(resolve),
|
||||
err => settle(() => reject(err)),
|
||||
)
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume()
|
||||
return settle(() => reject(new Error(`上游返回 ${status}`)))
|
||||
}
|
||||
let offset = 0
|
||||
// transform/onChunk 由调用方提供(路由层会在解密结果非音频时 throw)。
|
||||
// 它们在 data 事件回调里同步执行:若直接抛出会逃逸成 uncaughtException 并终止整个进程,
|
||||
// 因此必须就地捕获、转成 reject,并 destroy 上游停止后续读取。
|
||||
const fail = (err: unknown) => settle(() => {
|
||||
res.destroy()
|
||||
reject(err)
|
||||
})
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
if (settled) return
|
||||
try {
|
||||
const plain = transform(chunk, offset)
|
||||
offset += chunk.length
|
||||
onChunk(plain, offset - plain.length)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
}
|
||||
})
|
||||
res.on('end', () => settle(resolve))
|
||||
res.on('error', fail)
|
||||
})
|
||||
req.on('error', (err: unknown) => settle(() => reject(err)))
|
||||
})
|
||||
@@ -272,6 +272,12 @@ export const COMPONENT_SCHEMAS = {
|
||||
songList: { type: 'boolean' },
|
||||
singer: { type: 'boolean', description: '支持歌手接口(/singer/:source/:id)' },
|
||||
album: { type: 'boolean', description: '支持专辑接口(/album/:source/:id)' },
|
||||
albumSearch: { type: 'boolean', description: '支持专辑搜索(/album/:source/search/:keyword)' },
|
||||
playlist: { type: 'boolean', description: '支持歌单详情(/playlist/:source/:id)' },
|
||||
playlistSearch: { type: 'boolean', description: '支持歌单搜索' },
|
||||
playlistCategories: { type: 'boolean', description: '支持歌单分类' },
|
||||
parse: { type: 'boolean', description: '支持链接解析(/parse/:source)' },
|
||||
userPlaylist: { type: 'boolean', description: '支持个人歌单(/user-playlist/:source)' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
@@ -285,6 +291,87 @@ export const COMPONENT_SCHEMAS = {
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
PlaylistSummary: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', description: '歌单/专辑 id' },
|
||||
name: { type: 'string' },
|
||||
cover: { type: 'string' },
|
||||
trackCount: { type: 'integer' },
|
||||
playCount: { type: 'integer' },
|
||||
creator: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
link: { type: 'string' },
|
||||
source: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
PlaylistSearchResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
list: { type: 'array', items: ref('PlaylistSummary') },
|
||||
total: { type: 'integer' },
|
||||
page: { type: 'integer' },
|
||||
limit: { type: 'integer' },
|
||||
source: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
PlaylistDetailResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string' },
|
||||
info: ref('AlbumInfo'),
|
||||
list: { type: 'array', items: ref('SongItem') },
|
||||
page: { type: 'integer' },
|
||||
limit: { type: ['integer', 'string', 'null'] },
|
||||
total: { type: ['integer', 'string', 'null'] },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
PlaylistCategoriesResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string' },
|
||||
list: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
name: { type: 'string' },
|
||||
group: { type: 'string' },
|
||||
hot: { type: 'boolean' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
ParseResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
source: { type: 'string' },
|
||||
type: { type: 'string', description: '解析类型:song / album / playlist' },
|
||||
song: ref('SongItem'),
|
||||
playlist: ref('PlaylistSummary'),
|
||||
list: { type: 'array', items: ref('SongItem'), description: 'album/playlist 解析时的曲目' },
|
||||
total: { type: 'integer' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
UserPlaylistResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
list: { type: 'array', items: ref('PlaylistSummary') },
|
||||
page: { type: 'integer' },
|
||||
limit: { type: 'integer' },
|
||||
total: { type: 'integer' },
|
||||
source: { type: 'string' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
HealthResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
|
||||
+91
-5
@@ -5,8 +5,13 @@ 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'
|
||||
import { status as qianStatus } from '../sdk/qian/index.ts'
|
||||
import { status as jooxStatus } from '../sdk/joox/index.ts'
|
||||
import { status as appleStatus } from '../sdk/apple/index.ts'
|
||||
import { status as fivesingStatus } from '../sdk/5sing/index.ts'
|
||||
import * as lrAuth from '../sdk/lr/auth.ts'
|
||||
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
|
||||
import { refreshAll } from './refresh.ts'
|
||||
@@ -288,8 +293,11 @@ export const loginRoutes = async(fastify) => {
|
||||
fastify.post('/login/qs/qr', {
|
||||
schema: {
|
||||
summary: '获取汽水音乐扫码登录二维码',
|
||||
description: '用「抖音 APP」扫码(汽水 App 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' +
|
||||
'返回 qrimg(data URL)可直接展示,token 需原样回传。登录后 qs 取流走签名 track_v2,解锁完整曲目。',
|
||||
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) => {
|
||||
@@ -303,7 +311,7 @@ export const loginRoutes = async(fastify) => {
|
||||
fastify.post('/login/qs/qr/check', {
|
||||
schema: {
|
||||
summary: '轮询汽水音乐扫码状态',
|
||||
description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据)。',
|
||||
description: 'code:800 二维码过期/被拒 / 801 等待扫码 / 802 已扫码待确认(含需二次验证)/ 803 登录成功(自动保存凭据)。',
|
||||
tags: ['登录'],
|
||||
body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] },
|
||||
},
|
||||
@@ -382,6 +390,78 @@ export const loginRoutes = async(fastify) => {
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 懒人听书 ----------
|
||||
|
||||
fastify.post('/login/lr', {
|
||||
schema: {
|
||||
summary: '懒人听书账号登录',
|
||||
description: 'm.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化到 data/auth.json,' +
|
||||
'此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。',
|
||||
tags: ['登录'],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
account: { type: 'string', description: '账号(手机号)' },
|
||||
password: { type: 'string', description: '密码(明文传入,服务端自行编码)' },
|
||||
},
|
||||
required: ['account', 'password'],
|
||||
},
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
const { account, password } = request.body
|
||||
try {
|
||||
return await lrAuth.login({ account, password })
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/login/lr', {
|
||||
schema: { summary: '退出懒人听书登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('lr')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 哔哩哔哩 ----------
|
||||
|
||||
fastify.post('/login/bili/qr', {
|
||||
schema: {
|
||||
summary: '获取哔哩哔哩扫码登录二维码',
|
||||
description: '用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await biliAuth.createQRLogin()
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post('/login/bili/qr/check', {
|
||||
schema: {
|
||||
summary: '轮询哔哩哔哩扫码状态',
|
||||
description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' +
|
||||
'/ expired 二维码过期 / failed 失败。code 为 B站原始返回码(86101/86038/86090/0)。',
|
||||
tags: ['登录'],
|
||||
body: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await biliAuth.checkQRLogin(request.body.key)
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/login/bili', {
|
||||
schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('bili')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 通用 ----------
|
||||
|
||||
fastify.post('/login/refresh', {
|
||||
@@ -396,13 +476,14 @@ export const loginRoutes = async(fastify) => {
|
||||
fastify.get('/login/status', {
|
||||
schema: {
|
||||
summary: '查看登录状态',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间),qs/xm/me/lr 为已存凭据。',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' +
|
||||
'qs/xm/me/lr/qian/joox/apple/5sing 为已存凭据。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async() => {
|
||||
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
|
||||
const tx = getTxAuth()
|
||||
const txCred = getCredentials('tx') as { vipType?: string; updatedAt?: string } | null
|
||||
const txCred = getCredentials('tx') as { vipType?: string, updatedAt?: string } | null
|
||||
const kgCred = getCredentials('kg') as { updatedAt?: string } | null
|
||||
return {
|
||||
wy: wy ? { logged: true, ...wy } : { logged: false },
|
||||
@@ -412,6 +493,11 @@ export const loginRoutes = async(fastify) => {
|
||||
xm: xmStatus(),
|
||||
me: meStatus(),
|
||||
lr: lrAuth.status(),
|
||||
bili: biliAuth.status(),
|
||||
qian: qianStatus(),
|
||||
joox: jooxStatus(),
|
||||
apple: appleStatus(),
|
||||
'5sing': fivesingStatus(),
|
||||
}
|
||||
})
|
||||
}
|
||||
+302
-34
@@ -5,6 +5,7 @@
|
||||
|
||||
import { notFound, upstream } from './http/api-error.ts'
|
||||
import { signStream } from './http/stream-sign.ts'
|
||||
import { registerMiguStream, takeMiguStream } from './http/migu-stream.ts'
|
||||
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
|
||||
|
||||
const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
||||
@@ -48,12 +49,29 @@ const pId = { type: 'string', description: '平台歌曲 id', example: '28423286
|
||||
const qs = (properties: any, required: string[] = []) => ({ type: 'object', properties, required })
|
||||
const params = (properties: any, required?: string[]) => qs(properties, required ?? Object.keys(properties))
|
||||
|
||||
/** 能力位取值:支持 `album.searchAlbum` 形式的嵌套路径 */
|
||||
const capabilityOf = (m: any, capability?: string) => {
|
||||
if (!m || !capability) return m
|
||||
let cur = m
|
||||
for (const key of capability.split('.')) {
|
||||
if (cur == null) return undefined
|
||||
cur = cur[key]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
const getSource = (musicSdk: any, name: string, capability?: string) => {
|
||||
const m = musicSdk[name]
|
||||
if (!m || (capability && !m[capability])) throw notFound(`不支持的平台: ${name}`)
|
||||
if (!m || !capabilityOf(m, capability)) throw notFound(`不支持的平台: ${name}`)
|
||||
return m
|
||||
}
|
||||
|
||||
/** 歌单分类返回形状不一({list}/裸数组),统一为 { source, list } */
|
||||
const normalizeCategories = (source: string, res: any) => {
|
||||
const list = Array.isArray(res) ? res : (res?.list ?? [])
|
||||
return { source, list }
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全部业务路由
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
@@ -160,9 +178,14 @@ export const musicRoutes = async(fastify: any) => {
|
||||
// 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配)
|
||||
const finish = (res: any, via: string) => {
|
||||
if (!res?.url) throw new Error('获取播放地址失败')
|
||||
// 加密流(migu 的 Z3D/I3D):客户端无法自行解密,改指向服务端解密代理 /stream/migu/<票据>
|
||||
let rawUrl = String(res.url)
|
||||
if (res.encrypted && res.fileKey) {
|
||||
rawUrl = `/stream/migu/${registerMiguStream(rawUrl, String(res.fileKey))}`
|
||||
}
|
||||
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
|
||||
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
|
||||
let url = String(res.url)
|
||||
let url = rawUrl
|
||||
if (url.startsWith('/')) {
|
||||
const suffix = process.env.AUTH_KEY
|
||||
? `${url.includes('?') ? '&' : '?'}${signStream(process.env.AUTH_KEY, url.split('?')[0])}`
|
||||
@@ -237,27 +260,69 @@ export const musicRoutes = async(fastify: any) => {
|
||||
},
|
||||
}, urlHandler)
|
||||
|
||||
// qs 解密流代理:登录态取流返回 /stream/qs/:id 相对地址,本端点完成"签名 track_v2 →
|
||||
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"。raw 豁免 { code, data } 信封包装
|
||||
fastify.get('/stream/qs/:id', {
|
||||
// 加密流解密代理(qs 汽水解密流 / migu 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。
|
||||
// - qs:/url/qs 登录态取流返回相对地址 /stream/qs/<trackId>,本端点完成"签名 track_v2 →
|
||||
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"
|
||||
// - migu:/url/migu 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/migu/<票据>,本端点凭票下载密流逐字节解密
|
||||
// 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装
|
||||
fastify.get('/stream/:source/:id', {
|
||||
config: { raw: true },
|
||||
schema: {
|
||||
summary: '汽水音乐解密流代理',
|
||||
description: '登录态取流地址(/url/qs 返回的相对地址)。服务端完成 track_v2 签名请求与 cenc-aes-ctr 本地解密,返回明文音频。',
|
||||
summary: '加密流解密代理(qs / migu)',
|
||||
description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/migu 命中加密流时的地址),' +
|
||||
'服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' +
|
||||
'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' +
|
||||
'source=migu 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/migu 取流)。',
|
||||
tags: ['歌曲资源'],
|
||||
params: params({ id: pId }),
|
||||
params: params({
|
||||
source: { type: 'string', enum: ['qs', 'migu'], description: '平台 id:qs=汽水音乐解密流 / migu=咪咕加密流', example: 'qs' },
|
||||
id: { type: 'string', description: 'qs: 汽水 track id;migu: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
|
||||
}),
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
const entry = await fastify.musicSdk.qs.getStreamBuffer(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 { 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/migu 取流)')
|
||||
// 流式下载 + 逐块解密:咪咕 Z3D 密钥流按 32 字节循环,块内保持全局偏移即可,
|
||||
// 避免把上百 MB 的 wav/flac 整段读进内存(httpFetch 有 64MB 缓冲上限)
|
||||
const { miguZ3DStreamKey, isMiguAudioMagic } = await import('../sdk/migu/util.ts')
|
||||
const { pipeDecryptedMiguStream, miguStreamContentType } = await import('./http/migu-stream.ts')
|
||||
const streamKey = miguZ3DStreamKey(ticket.fileKey)
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': miguStreamContentType(ticket.url),
|
||||
'Accept-Ranges': 'none',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
})
|
||||
try {
|
||||
await pipeDecryptedMiguStream(ticket.url, (chunk, offset) => {
|
||||
const plain = Buffer.alloc(chunk.length)
|
||||
for (let i = 0; i < chunk.length; i++) plain[i] = chunk[i]! - streamKey[(offset + i) & 31]!
|
||||
return plain
|
||||
}, (plain, offset) => {
|
||||
if (offset === 0 && !isMiguAudioMagic(plain)) throw new Error('解密结果不是有效音频')
|
||||
reply.raw.write(plain)
|
||||
})
|
||||
reply.raw.end()
|
||||
} catch (err: any) {
|
||||
// 响应头已发出(流式),只能中断连接;未发出时走统一错误处理
|
||||
reply.raw.destroy(new Error(err?.message || 'migu 解密流失败'))
|
||||
}
|
||||
return reply
|
||||
})
|
||||
|
||||
const lyricHandler = async(request) => {
|
||||
@@ -382,7 +447,8 @@ export const musicRoutes = async(fastify: any) => {
|
||||
page: pPage,
|
||||
}, withPage ? undefined : ['source']),
|
||||
querystring: qs({
|
||||
limit: pLimit,
|
||||
// 注:本接口无 limit —— 各平台的 getList 签名不一致(如 kw 为 (sortId, tagId, page, tryNum)),
|
||||
// 条数由平台接口自身决定,无法统一透传;此前 schema 声明 limit 但实现忽略,属文档说谎
|
||||
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' },
|
||||
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' },
|
||||
}),
|
||||
@@ -447,7 +513,8 @@ export const musicRoutes = async(fastify: any) => {
|
||||
|
||||
const albumSchema = (withPage: boolean) => ({
|
||||
summary: '获取专辑详情与曲目列表',
|
||||
description: '支持 kg/kw/tx/xm/me(见 /sources 的 album 能力位)。kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
|
||||
description: '支持 kg/kw/tx/wy/xm/me/qt/lr/migu/qian/joox/apple(见 /sources 的 album 能力位)。' +
|
||||
'kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
|
||||
tags: ['歌手专辑'],
|
||||
params: params({
|
||||
source: pSource('album'),
|
||||
@@ -468,6 +535,205 @@ export const musicRoutes = async(fastify: any) => {
|
||||
return album.getAlbumListDetail(id, page).catch(err => { throw upstream(err.message) })
|
||||
})
|
||||
|
||||
fastify.get('/album/:source/search/:keyword', {
|
||||
schema: {
|
||||
summary: '搜索专辑',
|
||||
description: '支持 kg/kw/tx/wy/migu/qian/joox/apple(见 /sources 的 albumSearch 能力位)。',
|
||||
tags: ['歌手专辑'],
|
||||
params: params({ source: pSource('album.searchAlbum'), keyword: { type: 'string', description: '专辑名关键词', example: '魔杰座' } }),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('AlbumDetailResult', { notFoundDoc: false }),
|
||||
},
|
||||
}, async(request) => {
|
||||
const { source, keyword } = request.params
|
||||
const { page, limit } = request.query
|
||||
const m = getSource(musicSdk, source, 'album.searchAlbum').album
|
||||
try {
|
||||
const res = await m.searchAlbum(keyword, page, limit)
|
||||
// 各平台返回形状不一(有的 {list,total},有的裸数组),统一成列表形状
|
||||
if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length }
|
||||
return { source, ...res }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 歌单(搜索 / 详情 / 分类) ----------
|
||||
|
||||
fastify.get('/playlist/:source/search/:keyword', {
|
||||
schema: {
|
||||
summary: '搜索歌单',
|
||||
description: '支持 kg/kw/tx/wy/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。' +
|
||||
'部分平台该能力受限(如 qian 歌单搜索接口已下线、bili 需登录),失败时返回 502。',
|
||||
tags: ['歌单'],
|
||||
params: params({ source: pSource('playlist.searchPlaylist'), keyword: { type: 'string', example: '华语' } }),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('PlaylistSearchResult'),
|
||||
},
|
||||
}, async(request) => {
|
||||
const { source, keyword } = request.params
|
||||
const { page, limit } = request.query
|
||||
const m = getSource(musicSdk, source, 'playlist.searchPlaylist').playlist
|
||||
try {
|
||||
const res = await m.searchPlaylist(keyword, page, limit)
|
||||
if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length }
|
||||
return { source, ...res }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/playlist/:source/categories', {
|
||||
schema: {
|
||||
summary: '获取歌单分类',
|
||||
description: '支持 kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。',
|
||||
tags: ['歌单'],
|
||||
params: params({ source: pSource('playlist.getCategories') }),
|
||||
response: responses('PlaylistCategoriesResult'),
|
||||
},
|
||||
}, async(request) => {
|
||||
const m = getSource(musicSdk, request.params.source, 'playlist.getCategories').playlist
|
||||
try {
|
||||
return normalizeCategories(request.params.source, await m.getCategories())
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
const categoriesResult = async(source: string, categoryId: string, page: number, limit: number) => {
|
||||
const m = getSource(musicSdk, source, 'playlist').playlist
|
||||
if (typeof m.getCategoryPlaylists !== 'function') throw notFound(`平台 ${source} 不支持分类歌单`)
|
||||
try {
|
||||
const res = await m.getCategoryPlaylists(categoryId, page, limit)
|
||||
if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length }
|
||||
return { source, ...res }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
fastify.get('/playlist/:source/categories/:categoryId', {
|
||||
schema: {
|
||||
summary: '获取分类下的歌单',
|
||||
description: 'categoryId 为 /playlist/:source/categories 返回的分类 id(部分平台 id 即分类名)。',
|
||||
tags: ['歌单'],
|
||||
params: params({
|
||||
source: pSource('playlist.getCategories'),
|
||||
categoryId: { type: 'string', description: '分类 id(分类列表返回)', example: '华语' },
|
||||
}),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('PlaylistSearchResult'),
|
||||
},
|
||||
}, async(request) => categoriesResult(
|
||||
request.params.source, request.params.categoryId, request.query.page, request.query.limit,
|
||||
))
|
||||
|
||||
const playlistSchema = (withPage: boolean) => ({
|
||||
summary: '获取歌单详情与曲目列表',
|
||||
description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:<id>:<mid>:<bvid>、' +
|
||||
'joox/qian/migu 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。',
|
||||
tags: ['歌单'],
|
||||
params: params({
|
||||
source: pSource('playlist'),
|
||||
id: { type: 'string', description: '歌单 id(或平台歌单链接)', example: '123456789' },
|
||||
page: pPage,
|
||||
}, withPage ? undefined : ['source', 'id']),
|
||||
querystring: qs({ limit: pLimit }),
|
||||
response: responses('PlaylistDetailResult'),
|
||||
})
|
||||
pagedGet('/playlist/:source/:id', playlistSchema, async(request) => {
|
||||
const { source, id } = request.params
|
||||
const { limit } = request.query
|
||||
const page = Number(request.params.page) || 1
|
||||
const m = getSource(musicSdk, source, 'playlist').playlist
|
||||
try {
|
||||
// 上游/移植的详情接口普遍自带分页(page, limit)
|
||||
return await m.getPlaylistDetail(id, page, limit)
|
||||
} catch (err: any) {
|
||||
// 部分平台详情签名只有 id:退化为取全量后本地切片
|
||||
try {
|
||||
const songs = await m.getPlaylistSongs(id)
|
||||
if (!Array.isArray(songs)) throw err
|
||||
const start = (page - 1) * limit
|
||||
return {
|
||||
source,
|
||||
info: { name: id, img: '', desc: '', author: '' },
|
||||
list: songs.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: songs.length,
|
||||
}
|
||||
} catch {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 链接解析(歌曲/专辑/歌单链接 → 结构化数据) ----------
|
||||
|
||||
const parseBodySchema = { type: 'object', properties: { link: { type: 'string', description: '平台链接或裸 id' } }, required: ['link'] }
|
||||
|
||||
const parseHandler = (kind: 'song' | 'album' | 'playlist') => async(request: any) => {
|
||||
const { source } = request.params
|
||||
const link = String(request.body?.link ?? '').trim()
|
||||
const m = getSource(musicSdk, source, 'parse')
|
||||
const impl = m.parse?.[`parse${kind[0]!.toUpperCase()}${kind.slice(1)}`]
|
||||
if (typeof impl !== 'function') throw notFound(`平台 ${source} 不支持解析该类型链接`)
|
||||
try {
|
||||
const out = await impl(link)
|
||||
// 统一响应:{ source, type, song | playlist+list }
|
||||
if (kind === 'song') {
|
||||
if (out?.list || out?.songs) {
|
||||
const list = out.list ?? out.songs
|
||||
return { source, type: 'playlist', playlist: out.info ?? out.playlist ?? null, list, total: list.length }
|
||||
}
|
||||
return { source, type: 'song', song: out }
|
||||
}
|
||||
const list = out?.list ?? out?.songs ?? []
|
||||
return { source, type: kind, playlist: out?.info ?? out?.playlist ?? out, list, total: list.length }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
for (const kind of ['song', 'album', 'playlist'] as const) {
|
||||
fastify.post(`/parse/${kind}/:source`, {
|
||||
schema: {
|
||||
summary: `解析${kind === 'song' ? '歌曲' : kind === 'album' ? '专辑' : '歌单'}链接`,
|
||||
description: '把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;' +
|
||||
'5sing 歌曲/歌单、apple/bili/joox/qian/migu 三类、kw/kg/tx/wy 部分支持。',
|
||||
tags: ['链接解析'],
|
||||
params: params({ source: pSource('parse.parseSong') }),
|
||||
body: parseBodySchema,
|
||||
response: responses('ParseResult'),
|
||||
},
|
||||
}, parseHandler(kind))
|
||||
}
|
||||
|
||||
// ---------- 个人歌单(需登录态) ----------
|
||||
|
||||
fastify.get('/user-playlist/:source', {
|
||||
schema: {
|
||||
summary: '获取个人歌单列表',
|
||||
description: '需先登录(见 /login/*)。当前仅 tx/wy 有完整实现(见 /sources 的 userPlaylist 能力位);' +
|
||||
'未登录或平台不支持时返回 404/502。',
|
||||
tags: ['歌单'],
|
||||
params: params({ source: pSource('userPlaylist.getList') }),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('UserPlaylistResult'),
|
||||
},
|
||||
}, async(request) => {
|
||||
const { source } = request.params
|
||||
const { page, limit } = request.query
|
||||
const m = getSource(musicSdk, source, 'userPlaylist.getList').userPlaylist
|
||||
try {
|
||||
const list = await m.getList(page, limit)
|
||||
return { source, list: Array.isArray(list) ? list : (list?.list ?? []), page, limit }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 系统 ----------
|
||||
|
||||
fastify.get('/health', {
|
||||
@@ -494,22 +760,24 @@ export const musicRoutes = async(fastify: any) => {
|
||||
platforms: musicSdk.sources.map(({ id, name }) => {
|
||||
const m = musicSdk[id] || {}
|
||||
const qualities = musicSdk.supportQuality?.[id] ?? []
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
qualities,
|
||||
capabilities: {
|
||||
search: Boolean(m.musicSearch),
|
||||
url: Boolean(m.getMusicUrl),
|
||||
lyric: Boolean(m.getLyric),
|
||||
pic: Boolean(m.getPic),
|
||||
boards: Boolean(m.leaderboard),
|
||||
hotSearch: Boolean(m.hotSearch),
|
||||
songList: Boolean(m.songList),
|
||||
singer: Boolean(m.singer),
|
||||
album: Boolean(m.album),
|
||||
},
|
||||
const caps = {
|
||||
search: Boolean(m.musicSearch),
|
||||
url: Boolean(m.getMusicUrl),
|
||||
lyric: Boolean(m.getLyric),
|
||||
pic: Boolean(m.getPic),
|
||||
boards: Boolean(m.leaderboard),
|
||||
hotSearch: Boolean(m.hotSearch),
|
||||
songList: Boolean(m.songList),
|
||||
singer: Boolean(m.singer),
|
||||
album: Boolean(m.album?.getAlbumDetail || m.album?.getAlbumListDetail),
|
||||
albumSearch: Boolean(m.album?.searchAlbum),
|
||||
playlist: Boolean(m.playlist?.getPlaylistDetail),
|
||||
playlistSearch: Boolean(m.playlist?.searchPlaylist),
|
||||
playlistCategories: Boolean(m.playlist?.getCategories),
|
||||
parse: Boolean(m.parse?.parseSong || m.parse?.parseAlbum || m.parse?.parsePlaylist),
|
||||
userPlaylist: Boolean(m.userPlaylist?.getList),
|
||||
}
|
||||
return { id, name, qualities, capabilities: caps }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
+17
-9
@@ -1,19 +1,26 @@
|
||||
// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API
|
||||
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动)
|
||||
//
|
||||
// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略时聚合全部平台)
|
||||
// GET /find?name=&singer=&albumName=&interval= 跨平台精确匹配
|
||||
// GET /search/:source/:keyword[/:page] 搜索(source 为平台 id 或 all 聚合)
|
||||
// GET /find/:source/:keyword 跨平台精确匹配
|
||||
// GET /url/:source/:id 播放地址
|
||||
// GET /lyric/:source/:id 歌词
|
||||
// GET /pic/:source/:id 封面
|
||||
// GET /boards/:source 榜单列表
|
||||
// GET /boards/:source/:boardId 榜单详情
|
||||
// GET /boards/:source/:boardId[/:page] 榜单详情
|
||||
// GET /hot-search/:source 热搜词
|
||||
// GET /song-list/:source 歌单列表
|
||||
// GET /song-list/:source[/:page] 歌单列表
|
||||
// GET /singer/:source/:id 歌手信息(kg/tx/wy)
|
||||
// GET /singer/:source/:id/songs 歌手歌曲(kg/tx/wy)
|
||||
// GET /singer/:source/:id/albums 歌手专辑(kg/tx/wy)
|
||||
// GET /album/:source/:id 专辑详情+曲目(kg/kw)
|
||||
// GET /singer/:source/:id/songs[/:page] 歌手歌曲(kg/tx/wy)
|
||||
// GET /singer/:source/:id/albums[/:page] 歌手专辑(kg/tx/wy)
|
||||
// GET /album/:source/:id[/:page] 专辑详情+曲目
|
||||
// GET /album/:source/search/:keyword 专辑搜索
|
||||
// GET /playlist/:source/:id[/:page] 歌单详情+曲目
|
||||
// GET /playlist/:source/search/:keyword 歌单搜索
|
||||
// GET /playlist/:source/categories[/:categoryId] 歌单分类
|
||||
// POST /parse/{song|album|playlist}/:source 链接解析
|
||||
// GET /user-playlist/:source 个人歌单
|
||||
// GET /stream/:source/:id 解密流代理(source: qs 汽水 / migu 咪咕)
|
||||
// GET /sources /health GET /docs /redoc /openapi.json
|
||||
//
|
||||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||||
@@ -40,8 +47,9 @@ import { redocHtml } from './openapi/ui.ts'
|
||||
const PORT = process.env.PORT || 16688
|
||||
const DOCS_PREFIX = '/docs'
|
||||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)平台。'
|
||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
|
||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ migu(咪咕)/ qian(千千)/ ' +
|
||||
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。'
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
||||
|
||||
|
||||
+249
-13
@@ -7,6 +7,7 @@
|
||||
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
// 端口策略:默认在随机空闲端口自起独立实例(避免连到 16688 等常驻实例——
|
||||
@@ -75,13 +76,33 @@ const test = async(name: string, fn: () => Promise<any>) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试数据目录:与真实 data/ 隔离,避免 /login/refresh 等用例改写开发者的真实登录态
|
||||
const TEST_DATA_DIR = path.join(process.cwd(), 'tmp', 'test-data')
|
||||
|
||||
// 已启动的全部子实例。无论脚本正常收尾还是中途抛错,退出时统一回收 ——
|
||||
// 否则异常路径会留下监听端口的孤儿 node(历史上曾累积 5 个泄漏实例)
|
||||
const spawned: any[] = []
|
||||
process.on('exit', () => {
|
||||
for (const proc of spawned) {
|
||||
try { proc.kill() } catch { /* 已自行退出 */ }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 启动本地服务器(指定端口,可覆盖环境变量)并返回进程句柄
|
||||
* 显式关闭外部音源(SOURCE_ENABLED=''):本套用例验证的是 SDK 官方直连通道,
|
||||
* 若继承开发者 .env 的 SOURCE_ENABLED=true,取流会先走 sources/ 脚本链,掩盖 SDK 侧回归。
|
||||
*/
|
||||
const startLocalServer = (port: number, envExtra: Record<string, string> = {}) => new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['src/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, PORT: String(port), ...envExtra },
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
SOURCE_ENABLED: '',
|
||||
DATA_DIR: TEST_DATA_DIR,
|
||||
...envExtra,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
@@ -90,16 +111,22 @@ const startLocalServer = (port: number, envExtra: Record<string, string> = {}) =
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error('启动本地服务器超时'))
|
||||
}, 10000)
|
||||
reject(new Error(`启动本地服务器超时(30s)\n--- 子进程输出(末 2000 字)---\n${stdout.slice(-2000) || '(无输出)'}`))
|
||||
}, 30000)
|
||||
|
||||
const check = () => {
|
||||
// 子进程提前退出(端口占用 / 语法错误等):立即带输出报错,不再空等到超时
|
||||
if (proc.exitCode !== null) {
|
||||
clearTimeout(timer)
|
||||
return reject(new Error(`本地服务器进程已退出(code ${proc.exitCode})\n--- 输出(末 2000 字)---\n${stdout.slice(-2000) || '(无输出)'}`))
|
||||
}
|
||||
// 启用 AUTH_KEY 的实例全路径要求秘钥,探活也须携带
|
||||
const headers = envExtra.AUTH_KEY ? { 'X-Server-Key': envExtra.AUTH_KEY } : {}
|
||||
http.get(`http://localhost:${port}/health`, { headers }, res => {
|
||||
res.resume()
|
||||
if (res.statusCode === 200) {
|
||||
clearTimeout(timer)
|
||||
spawned.push(proc)
|
||||
resolve(proc)
|
||||
} else {
|
||||
setTimeout(check, 200)
|
||||
@@ -123,7 +150,8 @@ if (externalServer) {
|
||||
})
|
||||
const port = PORT || freePort
|
||||
BASE = `http://localhost:${port}`
|
||||
localServer = await startLocalServer(port)
|
||||
// 主实例关闭限流:用例数已超默认阈值(120/min),限流行为由下方独立低阈值实例专项验证
|
||||
localServer = await startLocalServer(port, { RATE_LIMIT: '0' })
|
||||
console.log(`本地服务器已启动(:${port})\n`)
|
||||
}
|
||||
|
||||
@@ -148,12 +176,19 @@ await test('聚合搜索', async() => {
|
||||
// kw 匿名通道 2026-10-01 接入 mobi.s convert_url_with_sign 主通道后:
|
||||
// 免费歌全档位可用,VIP 歌也放行真音频(320k/flac 实测与 N_MINFO 元数据逐字节吻合),
|
||||
// web playUrl 仅作回退(匿名恒 128k)
|
||||
// 酷我 CDN 回吐的 url query 用 $ 顶替 =(?bitrate$320&format$mp3…),非标准分隔符,
|
||||
// 取了 kw 的流就必须确认 SDK 已归正,否则播放器解析不出参数
|
||||
const assertKwUrl = (url: string): string => {
|
||||
if (url.includes('$')) throw new Error(`URL 含未归正的 $ 分隔符: ${url}`)
|
||||
return url.slice(0, 60)
|
||||
}
|
||||
|
||||
await test('URL kw 128k 免费歌', async() => {
|
||||
const r = await get('/url/kw/151826618?quality=128k')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
if (r.data.via !== undefined) throw new Error(`直接命中不应有 via 字段,实际 ${r.data.via}`)
|
||||
return r.data.url.slice(0, 60)
|
||||
return assertKwUrl(r.data.url)
|
||||
})
|
||||
|
||||
await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => {
|
||||
@@ -161,7 +196,7 @@ await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => {
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
// mobi 通道严格校验 bitrate==320 才放行,url 必然是 320k 档
|
||||
return r.data.url.slice(0, 60)
|
||||
return assertKwUrl(r.data.url)
|
||||
})
|
||||
|
||||
await test('URL kw VIP 歌 flac 降级链', async() => {
|
||||
@@ -169,7 +204,7 @@ await test('URL kw VIP 歌 flac 降级链', async() => {
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
// 晴天无 hi-res 档,应逐级降级命中 2000k flac 而非报错
|
||||
return r.data.url.slice(0, 60)
|
||||
return assertKwUrl(r.data.url)
|
||||
})
|
||||
|
||||
await test('URL wy 128k', async() => {
|
||||
@@ -479,6 +514,202 @@ await test('专辑详情 /album/lr', async() => {
|
||||
return `${r.data.info.total} 节`
|
||||
})
|
||||
|
||||
// ---- 新平台(migu/qian/joox/bili/5sing/apple 移植回归) ----
|
||||
|
||||
for (const source of ['migu', 'qian', 'joox', 'bili', '5sing', 'apple']) {
|
||||
await test(`搜索 ${source} 直查`, async() => {
|
||||
const r = await get(`/search/${source}/${encodeURIComponent(source === '5sing' ? '董贞' : '周杰伦')}?limit=5`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 条,首条 ${r.data.list[0].name}`
|
||||
})
|
||||
}
|
||||
|
||||
await test('URL migu(多档候选链)', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||
const song = r.data.list.find((s: any) => !s.payPlay) ?? r.data.list[0]
|
||||
const u = await get(`/url/migu/${encodeURIComponent(song.songmid)}?quality=128k`)
|
||||
if (u.code != 0) throw new Error(u.msg)
|
||||
const url = String(u.data.url)
|
||||
if (!url.startsWith('http') && !url.includes('/stream/migu/')) throw new Error('url 非法')
|
||||
return `实际 ${u.data.type},${url.slice(0, 60)}`
|
||||
})
|
||||
|
||||
await test('URL qian 免费曲', async() => {
|
||||
// qian 免费曲目少,用「SSR」(上游测试同款厂牌)搜到带直链的曲目
|
||||
const r = await get(`/search/qian/${encodeURIComponent('SSR')}?limit=10`)
|
||||
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||
const song = r.data.list.find((s: any) => !s.payPlay)
|
||||
if (!song) throw new Error('未搜到免费曲目')
|
||||
const u = await get(`/url/qian/${encodeURIComponent(song.songmid)}?quality=128k`)
|
||||
if (u.code != 0) throw new Error(u.msg)
|
||||
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
||||
})
|
||||
|
||||
await test('URL 5sing(档位 sq/hq/lq)', async() => {
|
||||
const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`)
|
||||
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||
const u = await get(`/url/5sing/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`)
|
||||
if (u.code != 0) throw new Error(u.msg)
|
||||
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
||||
})
|
||||
|
||||
await test('歌词 migu', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
const song = r.data?.list?.[0]
|
||||
if (!song) throw new Error('搜索失败')
|
||||
const l = await get(`/lyric/migu/${encodeURIComponent(song.songmid)}`)
|
||||
if (l.code != 0) throw new Error(l.msg)
|
||||
if (!l.data?.lyric) throw new Error('空歌词')
|
||||
return l.data.lyric.slice(0, 40)
|
||||
})
|
||||
|
||||
await test('歌词 5sing', async() => {
|
||||
const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`)
|
||||
const song = r.data?.list?.[0]
|
||||
if (!song) throw new Error('搜索失败')
|
||||
const l = await get(`/lyric/5sing/${encodeURIComponent(song.songmid)}`)
|
||||
if (l.code != 0) throw new Error(l.msg)
|
||||
if (!l.data?.lyric) throw new Error('空歌词')
|
||||
return l.data.lyric.slice(0, 40)
|
||||
})
|
||||
|
||||
await test('封面 migu', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
const song = r.data?.list?.[0]
|
||||
if (!song) throw new Error('搜索失败')
|
||||
const p = await get(`/pic/migu/${encodeURIComponent(song.songmid)}`)
|
||||
if (p.code != 0) throw new Error(p.msg)
|
||||
if (!String(p.data?.url ?? '').startsWith('http')) throw new Error('封面 url 非法')
|
||||
return String(p.data.url).slice(0, 60)
|
||||
})
|
||||
|
||||
for (const source of ['migu', 'qian', 'joox', 'apple']) {
|
||||
await test(`专辑搜索 /album/${source}/search`, async() => {
|
||||
const r = await get(`/album/${source}/search/${encodeURIComponent('Hello')}?limit=3`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 张,首张 ${r.data.list[0].name}`
|
||||
})
|
||||
}
|
||||
|
||||
await test('专辑详情 /album/migu', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
const albumId = r.data?.list?.find((s: any) => s.albumId)?.albumId
|
||||
if (!albumId) throw new Error('搜索结果未携带 albumId')
|
||||
const a = await get(`/album/migu/${albumId}`)
|
||||
if (a.code != 0) throw new Error(a.msg)
|
||||
if (!a.data?.list?.length) throw new Error('专辑无曲目')
|
||||
return `${a.data.info.name}(${a.data.total} 首)`
|
||||
})
|
||||
|
||||
for (const source of ['migu', 'qian', 'joox', 'apple']) {
|
||||
await test(`歌单分类 /playlist/${source}/categories`, async() => {
|
||||
const r = await get(`/playlist/${source}/categories`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空分类')
|
||||
return `${r.data.list.length} 个分类`
|
||||
})
|
||||
|
||||
await test(`分类歌单 /playlist/${source}/categories/:id`, async() => {
|
||||
const c = await get(`/playlist/${source}/categories`)
|
||||
const cat = c.data.list[0]
|
||||
if (!cat) throw new Error('无分类')
|
||||
const r = await get(`/playlist/${source}/categories/${encodeURIComponent(cat.id)}?limit=3`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('分类下无歌单')
|
||||
return `${r.data.list.length} 个歌单`
|
||||
})
|
||||
}
|
||||
|
||||
await test('歌单详情 /playlist/migu', async() => {
|
||||
const c = await get('/playlist/migu/categories')
|
||||
const cat = c.data.list[0]
|
||||
const pl = await get(`/playlist/migu/categories/${encodeURIComponent(cat.id)}?limit=3`)
|
||||
const id = pl.data?.list?.[0]?.id
|
||||
if (!id) throw new Error('未取到歌单 id')
|
||||
const d = await get(`/playlist/migu/${id}?limit=5`)
|
||||
if (d.code != 0) throw new Error(d.msg)
|
||||
if (!d.data?.list?.length) throw new Error('歌单无曲目')
|
||||
return `${d.data.total} 首,本页 ${d.data.list.length} 首`
|
||||
})
|
||||
|
||||
await test('歌单详情 /playlist/bili(合集)', async() => {
|
||||
const s = await get(`/playlist/bili/search/${encodeURIComponent('周杰伦')}?limit=3`)
|
||||
const id = s.data?.list?.[0]?.id
|
||||
if (!id) throw new Error('搜索无歌单')
|
||||
const d = await get(`/playlist/bili/${encodeURIComponent(id)}?limit=5`)
|
||||
if (d.code != 0) throw new Error(d.msg)
|
||||
if (!d.data?.list?.length) throw new Error('歌单无曲目')
|
||||
return `${d.data.total} 首`
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/song/migu', async() => {
|
||||
const r = await postJson('/parse/song/migu', { link: 'https://music.migu.cn/v3/music/song/600913000002601091' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.song?.songmid) throw new Error('缺少 song')
|
||||
return `${r.body.data.song.name} / ${r.body.data.song.singer}`
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/playlist/qian', async() => {
|
||||
const r = await postJson('/parse/playlist/qian', { link: 'https://music.91q.com/songlist/309735' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.list?.length) throw new Error('无曲目')
|
||||
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/album/apple', async() => {
|
||||
const r = await postJson('/parse/album/apple', { link: 'https://music.apple.com/us/album/the-life-of-a-showgirl-the-encore/6814997249' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.list?.length) throw new Error('无曲目')
|
||||
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
|
||||
})
|
||||
|
||||
await test('链接解析 不支持的平台 → 404', async() => {
|
||||
// qs 无 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' })
|
||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||
return 'qs 未实现 parse(按上游)'
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/song/kw(正向)', async() => {
|
||||
const r = await postJson('/parse/song/kw', { link: 'https://www.kuwo.cn/play_detail/28423286' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.song?.songmid) throw new Error('未返回 song.songmid')
|
||||
return `kw 解析成功: ${r.body.data.song.name}`
|
||||
})
|
||||
|
||||
await test('扫码二维码创建 POST /login/bili/qr', async() => {
|
||||
const r = await postRaw('/login/bili/qr')
|
||||
const body = JSON.parse(r.body)
|
||||
if (r.status !== 200 || body.code != 0 || !body.data?.key) throw new Error(r.body.slice(0, 120))
|
||||
return `key=${String(body.data.key).slice(0, 12)}…`
|
||||
})
|
||||
|
||||
await test('扫码轮询 POST /login/bili/qr/check', async() => {
|
||||
const created = JSON.parse((await postRaw('/login/bili/qr')).body)
|
||||
const key = created.data?.key
|
||||
if (!key) throw new Error('未取到 key')
|
||||
const r = await postJson('/login/bili/qr/check', { key })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
return `status=${r.body.data.status} code=${r.body.data.code}`
|
||||
})
|
||||
|
||||
await test('个人歌单 未支持的平台 → 502', async() => {
|
||||
const r = await getRaw('/user-playlist/5sing')
|
||||
if (r.status !== 502 && r.status !== 404) throw new Error(`期望 502/404,实际 ${r.status}`)
|
||||
return JSON.parse(r.body).msg?.slice(0, 60)
|
||||
})
|
||||
|
||||
await test('咪咕解密流代理 无票据 → 404', async() => {
|
||||
const r = await getRaw('/stream/migu/ffffffffffffffffffffffffffffffff')
|
||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||
return JSON.parse(r.body).msg?.slice(0, 50)
|
||||
})
|
||||
|
||||
await test('参数校验 422(非法页码)', async() => {
|
||||
const r = await getRaw(`/search/kw/${encodeURIComponent('稻香')}/0`)
|
||||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||||
@@ -594,6 +825,8 @@ 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 || '?'}`
|
||||
})
|
||||
|
||||
@@ -655,12 +888,15 @@ if (!externalServer && !process.env.AUTH_KEY) {
|
||||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||||
return 'ok'
|
||||
})
|
||||
// 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性
|
||||
for (const path of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) {
|
||||
const r = await authFetch(path)
|
||||
if (r.status !== 401) throw new Error(`${path} 期望 401,实际 ${r.status}(应全路径要求秘钥)`)
|
||||
}
|
||||
await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => 'ok')
|
||||
// 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性。
|
||||
// 断言必须包在 test() 内 —— 裸抛错会中断整个脚本,跳过后续用例与实例回收(留下孤儿进程)
|
||||
await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => {
|
||||
for (const p of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) {
|
||||
const r = await authFetch(p)
|
||||
if (r.status !== 401) throw new Error(`${p} 期望 401,实际 ${r.status}(应全路径要求秘钥)`)
|
||||
}
|
||||
return '6 条路径均 401'
|
||||
})
|
||||
// /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃
|
||||
await test('AUTH_KEY 流代理签名机制', async() => {
|
||||
// 裸秘钥 ?key= 已不生效
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# 专有二进制与构建产物不入库
|
||||
metasecml.dll
|
||||
*.exe
|
||||
*.log
|
||||
*.obj
|
||||
@@ -0,0 +1,186 @@
|
||||
# mssdk-sign —— 汽水音乐 metasec 签名 HTTP 服务
|
||||
|
||||
把汽水 PC 客户端自带的 `metasecml.dll`(字节 metasec,官方签名主体 *Douyin Vision Co., Ltd.*)
|
||||
关在一个进程里,对外只暴露一个 HTTP `/sign`。于是:
|
||||
|
||||
- **Windows**:直接跑。
|
||||
- **Linux**:丢进 **Wine** 跑,宿主机的服务(MusicServer 等)用 HTTP 调它 —— 专有 DLL 不出 Wine 前缀,宿主机不需要 Windows 运行时。
|
||||
|
||||
对应地,MusicServer 侧 `sdk/qs/mssdk.ts` 里那套「进程内 require bdms.node」可以换成「HTTP 调本服务」,
|
||||
跨平台部署就不用装官方客户端了(二进制仍需自己从官方客户端里取,不入库、不再分发)。
|
||||
|
||||
## 文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|---|---|
|
||||
| `mssdk-sign.cpp` | 全部实现(单文件,无第三方依赖:Win32 + Winsock + 自带 MD5/JSON) |
|
||||
| `build.bat` | Windows/MSVC 构建 |
|
||||
| `build-mingw.sh` | Linux 交叉编译成 exe(给 Wine 跑) |
|
||||
| `client.mjs` | Node 客户端示例(可直接 `node client.mjs --send` 验证) |
|
||||
| `REVERSE.md` | **逆向记录**:DLL 身份/壳/真实调用 ABI/行为规则/升级后如何重新定位偏移 |
|
||||
|
||||
## 构建
|
||||
|
||||
Windows(MSVC,需要 VS BuildTools):
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
:: 生成 mssdk-sign.exe
|
||||
mssdk-sign.exe --selftest --dll .\metasecml.dll
|
||||
```
|
||||
|
||||
Linux → Wine(MinGW-w64 交叉编译,静态链接,不依赖 libstdc++/winpthread DLL):
|
||||
|
||||
```bash
|
||||
sudo apt install mingw-w64
|
||||
./build-mingw.sh # 生成 mssdk-sign.exe
|
||||
```
|
||||
|
||||
> 注意:源码含中文注释/字符串,**MSVC 必须带 `/utf-8`**(`build.bat` 已带),否则会报一堆莫名其妙的语法错误。
|
||||
|
||||
## 运行
|
||||
|
||||
Windows:
|
||||
|
||||
```bat
|
||||
mssdk-sign.exe --port 16889 --dll .\metasecml.dll --token 你的口令 -v
|
||||
```
|
||||
|
||||
Wine(建议放在自己的 prefix 里,和 DLL 同目录):
|
||||
|
||||
```bash
|
||||
mkdir -p /opt/mssdk-sign && cd /opt/mssdk-sign
|
||||
cp mssdk-sign.exe metasecml.dll .
|
||||
WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --selftest --dll ./metasecml.dll # 先自检
|
||||
WINEPREFIX=/opt/wine-mssdk wine mssdk-sign.exe --port 16889 --bind 0.0.0.0 --token 你的口令 -v
|
||||
```
|
||||
|
||||
systemd 单元示例:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=mssdk-sign (metasec signer in Wine)
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=WINEPREFIX=/opt/wine-mssdk
|
||||
Environment=WINEDEBUG=-all
|
||||
WorkingDirectory=/opt/mssdk-sign
|
||||
ExecStart=/usr/bin/wine /opt/mssdk-sign/mssdk-sign.exe --port 16889 --bind 127.0.0.1 --token CHANGE_ME
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|---|---|
|
||||
| `--port <n>` | 监听端口,默认 16889 |
|
||||
| `--bind <addr>` | 默认 `127.0.0.1`;容器/跨机调用才用 `0.0.0.0`(务必配 token) |
|
||||
| `--dll <path>` | `metasecml.dll` 路径,默认 exe 同目录 |
|
||||
| `--token <t>` | 启用鉴权(`Authorization: Bearer <t>` 或 `X-Auth-Token: <t>`) |
|
||||
| `--sign-rva <hex>` | 手动指定签名函数偏移(默认按 DLL 版本表,见下) |
|
||||
| `--selftest` | 只做「加载 DLL + 签名自检」后退出(部署前必跑) |
|
||||
| `-v` | 打印每个请求的签名耗时 |
|
||||
|
||||
## 接口
|
||||
|
||||
### `GET /healthz`(免鉴权)
|
||||
|
||||
```json
|
||||
{"ok":true,"service":"mssdk-sign","version":"0.1.0",
|
||||
"signer":"D:/MusicServer/soda-mssdk/metasecml.dll",
|
||||
"dllVersion":"4.6.1.0","signRva":"0x52af0","auth":true}
|
||||
```
|
||||
|
||||
### `POST /sign`
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"url": "https://api.qishui.com/luna/pc/track_v2?aid=386088&…", // 必填,带完整 query
|
||||
"body": "{\"track_id\":\"…\"}", // 可选:给了且没带 x-ss-stub 时,服务端自动补
|
||||
"headers": { // 必填:真实要发出去的那一份
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"user-agent": "LunaPC/3.7.0(452316191)",
|
||||
"x-luna-background-type": "foreground",
|
||||
"x-tt-trace-id": "00-…-…-01"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"headers": {"X-Helios":"…","X-Medusa":"…","X-Neptune":"…"},
|
||||
"added": {"x-ss-stub":"DBA20AB94302CB03AC25A216DD953258"},
|
||||
"elapsed_ms": 43
|
||||
}
|
||||
```
|
||||
|
||||
失败时:`{"ok":false,"error":"…"}` + 400(入参错)/ 401(token)/ 500(签名失败)。
|
||||
|
||||
### 三条硬性约定(踩过坑)
|
||||
|
||||
1. **`headers` 必须是真实要发出的那一份**,一个都不能少(尤其 `cookie`、`x-ss-stub`)。签名覆盖 URL + 全部请求头,
|
||||
少一个服务端就回 `HTTP 200 + 空 body`。
|
||||
2. **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也要随请求发出。本服务在你传了 `body` 时会自动算并回传
|
||||
(`added.x-ss-stub`),记得把它一起发出去。
|
||||
3. **设备要成套**:URL 里的 `device_id` / `iid` / `fp` 必须是你自己的设备;签名和 URL 里的 `device_id` 绑死,
|
||||
改了 URL 设备号 → 服务端 `200 + 空 body`(实测)。
|
||||
|
||||
### 客户端(Node)
|
||||
|
||||
```js
|
||||
import { signRequest } from './client.mjs'
|
||||
|
||||
const { headers, added } = await signRequest(url, headersToSend, body)
|
||||
await fetch(url, { method: 'POST', headers: { ...headersToSend, ...headers, ...added }, body })
|
||||
```
|
||||
|
||||
集成到 MusicServer 时,把 `sdk/qs/mssdk.ts` 的 `signQishuiRequest()` 换成上面这次 HTTP 调用即可
|
||||
(签名覆盖的 headers 与请求实际发出的 headers 保持同一份、同一顺序)。
|
||||
|
||||
自检:
|
||||
|
||||
```bash
|
||||
node client.mjs # healthz + 签一次(不发真实请求)
|
||||
node client.mjs --send # 顺带发给 api.qishui.com 验证签名被接受
|
||||
```
|
||||
|
||||
## 它是怎么调 DLL 的(逆向结论,2026-10-02)
|
||||
|
||||
- 导出表里的 `MSBridgeML` / `MSBridgeOV` / `o0o0` 都是幌子(桩 / 线程状态初始化),
|
||||
**真正的签名函数在 `image base + 0x52af0`**,原型:
|
||||
|
||||
```c
|
||||
char* sign(const char* url, const char* headers_lines); // headers: "名\r\n值\r\n…"
|
||||
```
|
||||
|
||||
- 返回 `"名\r\n值\r\n…"` 的签名头串,由 DLL 自己的释放函数(`base + 0x1d8c0`)回收。
|
||||
- **不需要 init**;DLL 版本(本机样本)= `metasecbaseml 4.6.1.0`。
|
||||
- 调用是**串行**的(内部有全局/TLS 状态),服务端用互斥锁包住,实测 20 并发全部成功。
|
||||
|
||||
## 客户端升级了怎么办(DLL 换了,偏移会变)
|
||||
|
||||
`--selftest` 会**大声失败**,不会静默出错。定位新偏移:
|
||||
|
||||
1. 从新版客户端里取 `metasecml.dll`(解包方式见 MusicServer 的 `sdk/qs/mssdk.ts` 注释)。
|
||||
2. 拿真实请求自检,确认签名是否被接受(`node client.mjs --send`)。
|
||||
3. 找新偏移:
|
||||
- 先在 `KNOWN_VERSIONS` 里加版本,用旧偏移试;不行就:
|
||||
- 用 MusicServer 仓库里的脚本脱壳 + 定位:`tmp/metasec-rebuild.py`(内存脱壳 + 重建 PE)、
|
||||
`tmp/metasec-call-test.py`(直接调用试偏移)、`tmp/bdms-abi.py`(读 `bdms.node` 运行时 IAT 槽,
|
||||
那里躺着真实地址)。找到后用 `--sign-rva 0xXXXXX` 验证,再写进 `KNOWN_VERSIONS`。
|
||||
|
||||
## 已知限制 / 未验证项
|
||||
|
||||
- **Wine 下未实测**(本机没有 Wine):DLL 依赖的都是 Wine 常规实现的 Win32 API(kernel32/advapi32/ole32/
|
||||
winhttp/ws2_32/imm32/…),但 metasec 有设备指纹采集,行为可能和原生不同。部署前请先在目标机跑
|
||||
`wine mssdk-sign.exe --selftest` 和 `node client.mjs --send`。若加载失败,先看 `WINEDEBUG=+loaddll`
|
||||
的缺失依赖,必要时 `winetricks corefonts vcrun2019`、`winecfg` 里把 Windows 版本设为 Win10。
|
||||
- `metasecml.dll` 是**官方专有二进制**(签名主体抖音视界),不入库、不再分发;本目录只放源码与构建脚本,
|
||||
DLL 自己从官方客户端取,放 exe 同目录即可。
|
||||
- 签名服务本身不联网、不存 cookie;但仍建议只监听 `127.0.0.1` 并设置 `--token`,不要暴露公网。
|
||||
@@ -0,0 +1,97 @@
|
||||
# metasec 签名组件逆向记录
|
||||
|
||||
> 面向维护者:**汽水客户端升级后签名失效时,先看这里。**
|
||||
> 相关代码:`mssdk-sign.cpp`(签名服务);构建与接口用法见同目录 `README.md`。
|
||||
> 记录时间:2026-10-02,样本:`soda-mssdk/{metasecml.dll, bdms.node}`。
|
||||
|
||||
## 1. 速览
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 组件身份 | 字节 metasec 官方组件,内部名 `metasecbaseml` **4.6.1.0**(2024-09-04) |
|
||||
| 数字签名 | Authenticode 主体 **Douyin Vision Co., Ltd.**(DigiCert G4 Code Signing) |
|
||||
| 保护 | 自解密壳 + 运行期解密字符串表;`LoadLibrary` 后内存里即为明文镜像 |
|
||||
| 真实签名入口 | `metasecml.dll + 0x52af0`(**本版本**),`char* sign(const char* url, const char* headers)` |
|
||||
| 需要 init 吗 | **不需要**,冷进程直接调即可出有效签名 |
|
||||
| 有效性 | 已端到端验证:签名被 `api.qishui.com` 接受;不签名 / 设备不匹配 → `HTTP 200 + 0 字节` |
|
||||
| 产出能力 | `X-Helios` + `X-Medusa`(部分场景附 `X-Neptune`);**不含** Argus/Ladon/Khronos/Gorgon/a_bogus |
|
||||
|
||||
## 2. 两个文件的关系
|
||||
|
||||
- `metasecml.dll`(8.8MB):算法本体。导出表只有 3 个符号,且**都是幌子**:
|
||||
- `o0o0`(0x191c0):线程局部状态初始化(读 TEB→TLS 槽,条件成立时 memset 一个 0x148 字节结构)。
|
||||
- `MSBridgeML` / `MSBridgeOV`(同为 0x1d010):同一个桩 —— 调一次内部函数后**无条件返回 true**,签名结果不经它返回。
|
||||
- `bdms.node`(164KB):官方客户端的 N-API 胶水。对 JS 暴露 `init` / `generateHttpSignatureHeaders` / `report`;
|
||||
通过**延迟导入** `metasecml.dll` 的 `o0o0`、`MSBridgeML` 来用 DLL。
|
||||
|
||||
## 3. 真实调用 ABI(核心)
|
||||
|
||||
```c
|
||||
// headers: 官方展平形式 —— 每行 "名字\r\n值",行间也用 "\r\n" 连接
|
||||
char* sign(const char* url, const char* headers_lines);
|
||||
```
|
||||
|
||||
- 地址:`image base + 0x52af0`(该处是 `jmp` 跳板,实体在 0x52b00)。
|
||||
- 返回:`"名字\r\n值\r\n…"` 的签名头串,堆分配;释放函数 = `image base + 0x1d8c0`。
|
||||
- 线程:内部有全局/TLS 状态,**串行调用**(服务端已加互斥锁)。
|
||||
- addon 是怎么拿到这个地址的:运行时 DLL 改写了它的 IAT 槽(`bdms.node` 的 `.data` 偏移 `0x25e38`),
|
||||
槽里躺着的就是 `base+0x52af0` —— 这解释了「导出表说是 0x1d010,实际却在 0x52af0」的矛盾。
|
||||
- 壳的形态:磁盘上只有 PE 头 + 单个 8.8MB 加密节 `.xs2`(熵≈8.0),`.text/.rdata/.data/.xs0` 均无 raw 数据;
|
||||
加载时解密进内存 → **内存 dump 即得完整镜像**(重定位已修好)。
|
||||
|
||||
## 4. 行为规则(实测)
|
||||
|
||||
| 场景 | 服务端反应 |
|
||||
|---|---|
|
||||
| 不带签名 | `HTTP 200 + 0 字节` |
|
||||
| 带正确签名 | `HTTP 200` + 正常 JSON(曲名/时长/档位齐全) |
|
||||
| 签名后改 URL 里的 `device_id` | `HTTP 200 + 0 字节` → **签名与 URL 设备参数绑死** |
|
||||
| headers 少一个(尤其 `cookie`、`x-ss-stub`) | `HTTP 200 + 0 字节` → 签名覆盖**全部**请求头 |
|
||||
|
||||
- **`x-ss-stub` = body 的 MD5(大写)**,既是签名输入也必须随请求发出。
|
||||
- **场景决定头集合**:传完整真实头 → `X-Helios` + `X-Medusa`(有时附 `X-Neptune`);
|
||||
只传一个 `user-agent` 这类极简头 → **只给 `X-Neptune`**(网页兜底场景)。
|
||||
所以调用方必须把「真实要发出的那一份头」整套传进来。
|
||||
- 输出含时间戳/nonce,**每次不同**属正常。
|
||||
- 签名有效 ≠ 能拿整曲:匿名仍只有 30 秒试听(实测 236.6s 曲目 `stream_duration=30`),
|
||||
**瓶颈是登录 sessionid,不是签名**。
|
||||
|
||||
## 5. 内部结构(RTTI 符号泄漏)
|
||||
|
||||
| 类 / 符号 | 作用 |
|
||||
|---|---|
|
||||
| `IMSHttpReqSignModule` / `MSHttpReqSignModule` | HTTP 请求签名(本服务的调用目标) |
|
||||
| `IMSWebsocketReqSignModule` | WebSocket 请求签名 |
|
||||
| `MSBlackBox` / `IMSBlackBoxItem` | 黑盒数据采集 |
|
||||
| `IMSSecDeviceIDModule` | 设备指纹(device id) |
|
||||
| `IMSRiskInspectModule` / `IMSRiskInspector` | 风控检测 |
|
||||
| `IMSReportModule` | 数据上报 |
|
||||
| `MSPBSInt32/64`、`MSPBString(List)`(`__ms__`) | protobuf 序列化 |
|
||||
| `MSNetInterceptor::FuncInterceptor` + `Cronet_UrlRequest_Create`、`Cronet_Engine_SetMD5Header` | 注入 Chromium Cronet 网络栈(`x-ss-stub` 的 MD5 由它算) |
|
||||
| `MSConfig` / `MSJSON` / `MSHttpClient` / `MSTaskManager` | 基础设施;日志用 spdlog,源路径 `utils/matrix/os/windows/ms_log.cc` |
|
||||
|
||||
## 6. 保护强度评估
|
||||
|
||||
- 壳是「自解密 + 字符串按需解密」,**没有代码虚拟化**;脱壳后是正常的 MSVC x64 代码,可读可跟。
|
||||
- 敏感字符串(如 `X-Helios`)静态搜不到,运行期才解密;镜像里没找到明文标准 AES S-box
|
||||
(只有 CRC32 表、Base64 表、TEA/Blowfish 类常量)。
|
||||
- 结论:**算法级复刻不划算**——黑盒调用的集成成本≈0,而且能随官方升级同步。
|
||||
|
||||
## 7. 客户端升级后怎么重新定位偏移
|
||||
|
||||
`--selftest` 会**大声失败**,不会静默签出无效头。定位步骤:
|
||||
|
||||
1. 从新版客户端取 `metasecml.dll`(解包方式见 `sdk/qs/mssdk.ts` 头部注释)。
|
||||
2. 跑 `mssdk-sign.exe --selftest --dll <新dll>`:会打印它读到的 DLL 版本;失败即偏移变了。
|
||||
3. 找新偏移(仓库 `tmp/` 下现成脚本):
|
||||
- `tmp/metasec-rebuild.py` —— 内存脱壳 + 重建可分析 PE;
|
||||
- `tmp/bdms-abi.py` —— 读 `bdms.node` 运行时 IAT 槽,直接拿到真实签名函数地址(**最省事**);
|
||||
- `tmp/metasec-call-test.py` —— 用候选偏移直接调用,看返回是否形如 `X-…\r\n…`。
|
||||
4. 用 `--sign-rva 0xXXXXX` 验证,通过后写进 `mssdk-sign.cpp` 的 `KNOWN_VERSIONS`。
|
||||
5. 复验:`node client.mjs --send` 应得到 `HTTP 200` + 非空 body。
|
||||
|
||||
## 8. 边界
|
||||
|
||||
- `metasecml.dll` / `bdms.node` 是**官方专有二进制**,不入库、不随代码分发;各自从自己的官方客户端取,放 exe 同目录。
|
||||
- 用途限于个人自用/互操作性研究;不要对外提供签名服务,也不要用于批量刷接口。
|
||||
- 签名服务本身不联网、不落 cookie;部署仍建议只监听 `127.0.0.1` 并设置 `--token`。
|
||||
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user