Compare commits

...
35 Commits
Author SHA1 Message Date
snowy-yang 76fb812a50 feat(login): wy cookie 绑定与 qs 短信/sessionid 登录
wy 新增 POST /login/wy(cookie 须含 MUSIC_U,account/get 实测校验、失效
401,与 kg 假 token 同款语义);qs 新增 POST /login/qs/sms、/login/qs/sms/login
(官方 PC 客户端 WebInterfaceSdk 同款 send_code/sms_login,失败映射官方文案,
2046 需去汽水 App 完成安全验证)与 POST /login/qs(sessionid 手动绑定,
user_info 端点恒 16 无权限、不做上游校验)。smoke 补 6 项登录校验用例。
2026-10-04 17:31:38 +08:00
snowy-yang 6b05fde64b refactor(qs)!: 手机端链路彻底清除——PC 签名成为唯一供给
安卓自签 crypto/ 整目录(11 文件)与 device.ts 设备注册删除,audiodecrypt
上移 qs/ 根,getDeviceIds 并入 passport(_qs_passport_device 持久化);
musicSearch 去指纹搜索(需 PC 签名,匿名搜索下线);mssdk 签名供给改三级:
QS_SIGN_SERVER 显式 → 本机 16689 /healthz 自动探测(qs-signer 的 mssdk-sign)
→ data/qs-sign.json 固定值兜底。
2026-10-04 17:31:33 +08:00
snowy-yang cec63f864e fix(bili): 取流 CDN 分级选址——backupUrl 按 host 分级探测,根治 PCDN 点歌卡死
playurl 的 baseUrl 大概率落在 PCDN(京东云无线宝 mcdn.bilivideo.cn、迅雷
mountaintoys 等),旧代码只取 baseUrl(backupUrl 0 处使用)且探测失败仍
原样返回坏地址,点歌端连 PCDN 挂死。现候选 = baseUrl+backupUrl 按 host
分级(upos/Akamai 正规 CDN > cn-* 等 bilivideo.com 系 > PCDN),探测改为
fetch 只读响应头即断开(不等 body,CDN 忽略 Range 回 200 全量时不再整文件
缓冲),单跳 8s / 总预算 20s / 至多 3 候选,全败回退最优候选(fail-soft);
CDN 探测不再携带 Cookie(直链自带签名,避免登录态泄给第三方 PCDN 域名)。
getAudioUrl 返回 urls[],parse 改取 urls[0]。

新增 test/bili-cdn-test.ts(SDK 层 PCDN 硬断言,并入 pnpm test)与 smoke
「URL bili(CDN 分级选址)」软断言项。
2026-10-04 17:29:28 +08:00
snowy-yang e1357dd412 refactor(api)!: 平台 id 回退——joox/5sing/bili/apple 恢复全名,目录对齐 bd/mg
- 撤销 3381e9b/1b972ec 中 jx/5s/bl/ap 四个短码,恢复 joox/5sing/bili/apple;
  qian→bd、migu→mg 保留
- 目录与文件对齐保留的新 id:sdk/qian/→sdk/bd/、sdk/migu/→sdk/mg/、
  sdk/apis/qian.ts→bd.ts、migu.ts→mg.ts、src/http/migu-stream.ts→mg-stream.ts
  (规则改为「目录=对外 id」,聚合入口的模块变量同步更名;music.migu.cn 等域名、
  上游 music-lib 对照注释、miguGet 等功能标识符不动)
- 对外 id 全表:kw kg tx wy qs xm me qt lr mg bd joox 5sing bili apple

**破坏性变更**:joox/5sing/bili/apple 四平台的 URL、/sources 返回与
data/auth.json 凭据键恢复全名,已存短码键的登录态需重新登录。

验证:typecheck ✓、pnpm test 112/112 ✓
2026-10-03 21:04:55 +08:00
snowy-yang 8c02fb5c15 refactor(sdk): 审计 P2 卫生项清理——请求层收敛与死代码清除
- 删除旧 sdk/request.ts,全部 import 收敛到 sdk/common/request.ts(85 文件)
- 注释死代码 630+ 行、裸 console.log 吞错 6 处、未使用符号 26 处清零
- bili 状态校验去重;utils DNS 预解析失败日志改 warn 带上下文
- CODE-AUDIT.md:第四节 P2 清理记录 + 第八节线索逐条复核结论(kw/kg/tx/wy parse 骨架相似实质各异,不抽公共层)
2026-10-03 20:48:54 +08:00
snowy-yang ecca1b8458 feat(qs): PC 签名改双模式供给,签名工具链迁出独立仓库 qs-signer
- mssdk.ts:进程内 bdms.node 加载移除;优先 QS_SIGN_SERVER 调签名服务
  (兼容 qs-signer 仓库 server.mjs 与 mssdk-sign 两个实现,/sign 契约互通),
  无服务时 QS_HELIOS/QS_MEDUSA 或 data/qs-sign.json 固定值兜底,都没有走安卓自签
- musicSearch:PC 通道签名失败/请求异常/风控空结果自动降级安卓通道(原为同通道重试)
- signer/、tools/mssdk-sign/、scripts/qs-local-login.mjs 迁至 qs-signer
  (gitea.4i.hk/hhhge/qs-signer),soda-mssdk/ 二进制目录一并挪走
2026-10-03 20:45:22 +08:00
snowy-yang 1b972ec4dd refactor(api)!: 平台 id 收尾统一——migu→mg、apple→ap
补上最后两个多字母 id,至此 15 个平台对外 id 全部为两字母:
kw kg tx wy qs xm me qt lr mg bd jx bl 5s ap

**破坏性变更**:id 出现在所有 URL、/sources 返回以及 data/auth.json 的
凭据键里,咪咕与 Apple Music 已存的登录态会失效,需重新登录。

- sdk/index.ts:id 与 supportQuality 映射键(展示名 `咪咕音乐` /
  `Apple Music` 保持不动)
- sdk/types.ts:Source 联合类型
- src/routes.ts:/stream 的 enum ['qs','migu'] → ['qs','mg'],以及
  /url/mg、/stream/mg 等路径与 schema 描述
- src/http/migu-stream.ts、src/routes-login.ts、src/server.ts
- test/smoke-test.ts、README.md、docs/API.md 同步

有意未改:域名与 API 路径(music.migu.cn、music.apple.com、
amp-api.music.apple.com、/v1/catalog/cn/apple-curators/…)、
标识符(registerMiguStream、appleAmpGet、APPLE_AMP_API_URL 等)、
sdk/migu 与 sdk/apple 的目录与文件名、错误文案里的平台名。

验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources
(15/15 平台 id 与展示名全对)与 /login/status,旧 id 零残留。
2026-10-03 11:07:21 +08:00
snowy-yang 3381e9b906 refactor(api)!: 平台 id 改两字母短码——qian→bd、joox→jx、5sing→5s、bili→bl
对外 id 统一为两字母短码,与既有的 kw/kg/tx/wy/qs/xm/me/qt/lr 对齐;
migu 与 apple 保持原样(Apple Music 没有自然的短码)。

**破坏性变更**:id 出现在所有 URL、/sources 返回、OpenAPI 示例以及
data/auth.json 的凭据键里,这四个平台已存的登录态会失效,需重新登录。

- sdk/index.ts:id 与 supportQuality 映射键
- sdk/types.ts:Source 联合类型
- src/routes.ts 与 routes-login.ts:路由路径与 schema 描述
  (/login/bili/qr → /login/bl/qr 等)、/login/status 的响应键
- test/smoke-test.ts、README.md、docs/API.md 同步

有意未改:sdk 目录与文件名仍用平台名(sdk/qian/、sdk/apis/joox.ts …),
规则定为「目录=平台名,id=对外短码」,映射只在 SUPPORTED_SOURCES 一处声明。
域名与正则(joox.com、5sing.kugou.com、/joox\.com\//)、jooxSign 等标识符、
错误文案里的中文平台名、以及 kg 榜单名(含「joox本地热歌榜」)全部保持原样——
它们只是恰好含平台名,并不是 id。

验证:typecheck ✓、pnpm test 112/112 ✓;另起临时实例实证 /sources 与
/login/status 的新 id 与展示名,新旧 id 均无残留。
2026-10-03 11:03:06 +08:00
snowy-yang 14f7295fd4 chore(tools): 入库汽水签名工具链(独立签名服务与逆向记录)
- tools/mssdk-sign/:C++ 实现的签名服务,附 mssdk 内部结构记录、MinGW 与
  MSVC 两套构建脚本、调用示例 client.mjs 与自检入口
- signer/server.mjs:Node 实现的等价签名服务,面向「主服务不在 Windows 上」
  的场景——bdms.node 是 Windows PE 原生模块,只能由 Windows 版 Node 加载,
  故把「加载官方模块」关进独立进程,主服务经 HTTP 取 X-Helios / X-Medusa

两者都不被 src/sdk 引用:运行期走 sdk/qs/mssdk.ts 直接加载官方 bdms.node,
它们是组件升级、签名失效时重新定位入口并验证的手段,放在仓库里才能跟着
代码一起维护。

专有二进制(mssdk-sign.exe、metasecml.dll)由 tools/mssdk-sign/.gitignore
排除,不随仓库分发。
2026-10-03 10:50:34 +08:00
snowy-yang dd28ba157c docs: 文档与实现对齐,并收录全量代码检查报告
- README:平台能力表按实际能力位修正(qs 歌单/专辑改 —、kw/kg/tx/wy 的
  parse 改 ✅、qt/lr 封面改 ✅),接口表补 album 分页位、parse/playlist
  支持平台、user-playlist 仅 tx/wy、DELETE 登录端点、qs 扫码方式;
  项目结构补 http/ 下 4 个文件与 routes-login 的 bili/lr
- docs/API.md:整篇改为指向 /openapi.json 的速查版,把唯一的接口事实
  来源收敛到 JSON Schema;平台数按实际 15 个更新
- CODE-AUDIT.md:新增,记录 2026-10-03 全量检查的 P0/P1/P2 与文档不一致项,
  顶部标注修复状态(已修 P0/P1,未动项明确列出)

为什么改写而不是逐条打补丁:文档与实现双向失配的地方太多,逐条修补会留下
自相矛盾的段落;改成「能力以 /sources 与 /openapi.json 为准,文档只做索引」
后,漂移的结构性来源就被消除了。
2026-10-03 00:27:30 +08:00
snowy-yang de2dd311ee feat(qs): 接入官方签名模块、修复扫码登录链路、新增本机登录脚本
- passport:响应自带的 qrcode 图指向已 404 的旧扫码页
  (ucenter_web/app/sdk-next),抖音扫码会提示「无法访问页面」;改为用
  qrcode_index_url 的 token 重建官方 bff-pc.qishui.com/light/invoke/scan_login
  地址,与客户端 renderer 的 getHandleScanCodeH5Img 算法一致。同时状态
  映射按官方 renderer 语义对齐(1/new 待扫码、2/scanned 已扫码待确认、
  3/confirmed 已确认),并采集轮询下发的 Set-Cookie、响应带 redirect_url
  时补跳一次,否则拿不到 sessionid
- qs/mssdk:进程内加载官方 bdms.node 生成 X-Helios / X-Medusa 签名,
  不另起 HTTP 签名服务;缺原生模块时整体降级,不影响匿名可用
- scripts/qs-local-login.mjs:直接读本机官方客户端的 Cookies 与 DeviceV1
  写入 data/auth.json,免扫码(独立脚本,不参与服务运行)

为什么需要 mssdk 签名:汽水对未签名请求回 HTTP 200 + 0 字节,把失败伪装
成空成功,没有签名就分不清「接口异常」与「风控拦截」。
2026-10-03 00:27:19 +08:00
snowy-yang 68e071b166 feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力
四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse
(tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。

- kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`),
  `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声;
  统一还原成 `=` 再对外返回
- wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐
- kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单

为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
2026-10-03 00:26:51 +08:00
snowy-yang 33b96f92c4 feat(sdk): 新增咪咕/千千/JOOX/哔哩哔哩/5sing/Apple Music 六平台
平台注册表由 9 个扩到 15 个,同时补齐 sdk/types.ts 的类型与
OpenAPI 文档里的能力位声明。

- migu:命中 Z3D/I3D 加密候选时不返回直链,改由服务端解密代理出流,
  SDK 侧只负责登记票据,避免客户端拿到无法播放的密文
- apple:只提供 preview 试听片段(完整曲为 DRM),标 noAggregate
  排除在跨平台精确匹配之外,防止用它顶掉其它平台的可播结果
- 其余平台按既有 PlatformModule 约定实现搜索/专辑/歌单/歌词

为什么合并成一次提交:六平台共用同一份注册表与能力位声明,
拆开会产生互相引用、单独无法通过类型检查的中间态。
2026-10-03 00:26:32 +08:00
snowy-yang 47ce651f46 fix: 修复代码审查发现的 P0/P1 缺陷(含测试加固)
审查明细见 CODE-AUDIT.md。本提交覆盖其中的代码级修复:

P0-2 migu 解密流异常逃逸 → 进程崩溃
  src/http/migu-stream.ts 把 transform/onChunk 就地 try/catch,转 reject 并 res.destroy()。
  为什么:二者在 res 的 data 事件回调里同步执行,抛出的异常既不在 Promise 链上、
  也不在路由层 try/catch 内,会逃逸成 uncaughtException(全项目无全局处理器)直接杀进程。
  已用最小复现验证:修复前 exit 9,修复后正常 reject。

P0-1 /login/lr 缺失 → lr 取流 100% 不可用
  src/routes-login.ts 补 POST/DELETE /login/lr。
  为什么:sdk/lr/auth.ts 早已实现 login、该文件也已 import lrAuth,只差路由注册;
  lr 取流强制要求登录态,缺此入口等于整个平台不可用,而 README 已将其文档化。

P0-3 + P1-7 凭据存储
  sdk/auth/store.ts 写入改 0600 + 「临时文件 + rename」原子替换;解析失败改为改名备份而非静默清空;
  新增 DATA_DIR 环境变量(测试隔离用)。
  为什么:原先直接覆写明文 JSON(0644)且解析失败静默置空,写入中途被杀会留下截断 JSON,
  下次启动解析失败后,任意一次 setCredentials 都会以空表覆盖,导致全部平台凭据丢失。

P1-9 / P1-10 / P1-11 / P1-12 平台模块缺陷
  sdk/kg/krcDecode.ts:空歌词显式 resolve('')(原先裸 return 让 Promise 永久 pending,请求挂死)
  sdk/kg/lyric.ts:重试改调 getLyricDownload(原先误调 getLyric,参数语义不同导致重试恒失败)
  sdk/kw/songList.ts:重试改自递归(原先误调 getListDetail,返回类型错位且把 tryNum 当 page)
  sdk/kw/songList.ts:删除 URL 模板中多余的右花括号

P1-6 /song-list 参数契约
  src/routes.ts 移除不生效的 limit 声明。
  为什么:各平台 getList 签名不一致(如 kw 为 (sortId, tagId, page, tryNum)),无法统一透传,
  保留声明只会让文档与实现不符。

P1-4/5/13/15 冒烟测试加固
  test/smoke-test.ts:子实例统一登记 + 进程退出回收(原先异常路径留下孤儿实例,曾累积 5 个);
  启动超时 10s→30s 且失败时附子进程输出、提前退出立即报错;显式 SOURCE_ENABLED='' 与 DATA_DIR
  隔离(原先继承 .env,取流用例走外部音源掩盖 SDK 回归,且 /login/refresh 会改写真实 data/auth.json);
  AUTH 断言移入 test()(原先裸抛错会跳过实例回收);修正 642 行过期断言并补 kw parse 正向用例。

P1-8 src/auth/store.ts 删除引用已不存在命令 `pnpm build` 的过期注释。
src/server.ts 头注释的接口清单改为 path 风格,与实际路由一致。

注:src/routes.ts、src/routes-login.ts、src/server.ts、test/smoke-test.ts 同时承载了工作区
既有的在途改动(parse/playlist/album 路由接入与相应用例),一并随本提交入库。
2026-10-03 00:23:25 +08:00
snowy-yang ece9c5fc9a chore(git): 忽略 agent 工作目录、取消跟踪本机配置
- .gitignore 增加 .workbuddy/ 与 .claude/:二者都是本机产物(记忆日志、工具权限配置),
  其中 .claude/settings.local.json 内容含本机绝对路径,已 git rm --cached 停止跟踪
  (为什么:避免把本机环境细节提交进公开仓库,也避免 git add . 误收)
- pnpm-workspace.yaml 补注释说明 allowBuilds 的用途
  (为什么:isolated-vm 的原生构建脚本必须显式放行,否则 pnpm 静默跳过 postinstall,
   node_modules/isolated-vm 无 build/Release/*.node,加载失败后 ivm 沙箱音源会静默降级)
2026-10-03 00:21:02 +08:00
snowy-yang cca7e63ec3 fix(dev): pnpm dev 容忍 sources/ 缺失——scripts/dev.mjs 仅为存在的目录注册 --watch-path 2026-10-02 21:09:20 +08:00
snowy-yang 436b102ae2 feat(api): 列表接口分页统一 path 段——boards/song-list/singer songs与albums/album 支持可选 /:page,弃用 ?page= 2026-10-02 20:57:31 +08:00
snowy-yang baa34a501c feat(api): search/find 改 path 风格——/search/:source/:keyword[/:page] 与 /url 同构,find 支持单平台/all 2026-10-02 20:51:50 +08:00
snowy-yang cf0874fc15 feat(auth): 流代理升级短时签名 URL——?exp=&sig=(HMAC 派生 10 分钟有效)替代裸 ?key=,秘钥不再落入 URL/日志 2026-10-02 20:39:25 +08:00
snowy-yang 415a04dea8 feat(auth): 统一 X-Server-Key 全路径鉴权(防扫描探测)+ per-IP 滑动窗口限流 2026-10-02 20:31:59 +08:00
snowy-yang 649ebbe12b feat(auth): 调用秘钥鉴权——AUTH_KEY 环境变量启用,Bearer/X-Api-Key/?key= 三种携带方式,流地址自动附带 key 2026-10-02 20:15:36 +08:00
snowy-yang bc1d23cbba fix(test): smoke 默认随机端口自起实例——避免连到 16688 常驻旧实例导致新用例误报 2026-10-02 20:08:35 +08:00
snowy-yang cd5d9b1c91 fix(me): 搜索对齐网页版——dramaapi/search 正剧置顶,d:<剧id> 条目自动取首个免费集 2026-10-02 20:08:35 +08:00
snowy-yang 1fec828547 feat: 平台注册与路由收尾——9 平台(含 xm/me/qt/lr 有声)、复合 id 约定、64k 档位、测试与文档 2026-10-02 19:43:21 +08:00
snowy-yang 6fb866e343 feat(lr): 懒人听书平台接入——App 通道搜索、web 章节列表、账密登录取流 2026-10-02 19:43:21 +08:00
snowy-yang 83d3eb3b49 feat(qt): 蜻蜓FM平台接入——audiostream HMAC-MD5 签名直链、SSR 搜索/频道节目列表 2026-10-02 19:43:20 +08:00
snowy-yang 020e9bbb2e feat(me): 猫耳FM平台接入——App API HMAC 签名取明文直链、匿名搜索/剧集、token 绑定 2026-10-02 19:43:20 +08:00
snowy-yang 5a9915b74c feat(xm): 喜马拉雅平台接入——匿名搜索/专辑/免费曲直链(hdaa xm-sign+AES-ECB 解密)、cookie 绑定解锁付费 2026-10-02 19:43:09 +08:00
snowy-yang 44852864e2 feat(tx): flac24bit 高阶音质候选链——RS01(Hi-Res)/Q000(大师)/AI00(臻品母带) 逐级回退 2026-10-02 19:43:08 +08:00
snowy-yang 3b40ba4c8f feat(kg): 账密登录与 20028 验证码处理——/v9/login_by_pwd、ssa-code 挑战链、行为指纹模拟、dfid 正式注册 2026-10-02 19:43:08 +08:00
snowy-yang e796e622f2 docs(readme): 补齐 qs 平台与双通道后的过期内容
- 平台列表/接口表补 qs(抖音扫码登录、/stream/qs 解密流代理)与取流链说明
- 歌曲 ID 约定补 qs,删掉已失效的 kg 风控告示(2026-10-01 /v5/url 已修复,
  VIP 歌改为需登录的准确表述)
- 外部音源章节说明候选链只覆盖 kw/kg/tx/wy、失败回落 SDK 与酷我兜底
- 已知限制重写:kg 登录取流、kw 严格档位校验与降级链、qs 试听/解密代理
- 项目结构注释补 qs 目录与 auth 续期描述
2026-10-01 22:54:05 +08:00
snowy-yang b3e1cc5f67 feat(url): 取流链兜底——外部音源→SDK 直连→酷我跨平台匹配
/url/:source/:id 顺序尝试:外部音源脚本(SOURCE_ENABLED 启用时)→
SDK 官方直连→都失败时用 findMusic 仅在 kw 内按歌名/歌手/时长匹配
同一首歌后走 kw 通道(匿名可拿无损)。kg 的 hash 自动反查歌曲信息,
其它平台可传 name/singer/interval 查询参数启用兜底;
kw 自身不再二次兜底。响应新增 via 字段标记实际命中通道
(script/平台 id/kw,直接命中不返回),type 改为实际音质(降级链生效);
全部失败返回聚合各阶段错误的 502。findMusic/searchMusic 增加 only 参数。
2026-10-01 22:54:00 +08:00
snowy-yang 0f9cff2a08 feat(qs): 汽水音乐平台接入——匿名搜索/试听、抖音扫码登录、解密流代理
- sdk/qs 平台模块(安卓参数指纹匿名搜索)+ sdk/apis/qs 取流:
  匿名 30s 试听,登录态走签名 track_v2 + spade key AES-CTR 解密
- /login/qs/qr 抖音扫码登录(授权后 sessionid 解锁完整曲目)
- /stream/qs/:id 解密流代理,/url 返回的相对地址按请求 host 补全
- 回归测试覆盖 kw 双通道(VIP 真音频/降级链)与 qs 试听;start.bat 去掉
  SOURCE_ENABLED 直启
2026-10-01 22:43:49 +08:00
snowy-yang aa4f2eec67 feat(kw): 取流双通道重构——mobi.s convert_url_with_sign 主通道
主通道 mobi.s(keluze 指纹,user=0 匿名):VIP/付费歌放行真音频,
晴天 320k mp3/2000k flac 与 N_MINFO 元数据逐字节吻合,hi-res 可用;
档位缺失会静默降级甚至吐 mgg/mflac 加密,必须严格校验
「返回 bitrate==请求档位 且格式为明文」否则沿降级链换档重试。
回退通道 web playUrl(Hm_Iuvt cookie 异或哈希 Secret 签名):匿名恒 128k,
VIP 歌明确拒绝,仅作 mobi 网络故障兜底。旧 antiserver 链移除。
2026-10-01 22:43:43 +08:00
snowy-yang ddc44a276d fix(kg): /v5/url 成功响应按顶层 url/backupUrl 数组解析
网关成功(status:1)时播放地址在顶层 url: string[] 与 backupUrl: string[],
并非旧 data:[{url}] 结构,导致 VIP 登录后所有取流仍报"获取失败";
旧结构保留为兜底。album_audio_id/album_id 加 Number.isFinite 守卫——
/url 路由把 hash 传给 songmid 时 Number(hash)=NaN 会被网关拒答。
实测珊瑚海四档(VIP)全通,320k/flac/hi-res 与曲库大小逐字节吻合。
2026-10-01 22:43:29 +08:00
168 changed files with 14306 additions and 1157 deletions

No files matched your search

-10
View File
@@ -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 '\\\\.\\\\./\\\\.\\\\./\")"
]
}
}
+4
View File
@@ -22,6 +22,10 @@ Thumbs.db
# ZCode 本地目录 # ZCode 本地目录
.zcode/ .zcode/
# Agent 工作目录(本机记忆日志、工具权限配置;含本机绝对路径,不入库)
.workbuddy/
.claude/
# IDE # IDE
.vscode/ .vscode/
.idea/ .idea/
+335
View File
@@ -0,0 +1,335 @@
# MusicServer 全量代码检查报告
**检查时间**:2026-10-03
**修复状态**(复查后更新):P0-1 ~ P0-3、P1-4 ~ P1-16 **全部已修复**(含测试隔离与 isolated-vm 原生构建);
第四节 P2 卫生项已于 2026-10-03 清理完毕(注释死代码 630+ 行、裸 `console.log` 吞错 6 处、未使用符号 26 处清零、bili 状态校验去重);
第八节线索逐条复核:qian 导出名、migu 异常消息、Quality `preview`、bili 重试重复均已过时(早前重写时已解决),kw/kg/tx/wy parse/playlist 仅有骨架相似、实质各异,评估后不抽公共层。
第五节「文档与代码不一致」已按 `/sources` 与路由实现逐条校准。
各条的修复要点见下文对应条目。
**检查范围**:`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 附最小复现脚本);本节线索建议复核后再动手。第四组「测试与文档」专项审查仍在进行,其领域(测试有效性、覆盖缺口、文档一致性)本报告已通过独立核对覆盖主要结论。
+116 -20
View File
@@ -4,9 +4,32 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)、咪咕音乐(mg)、千千音乐(bd)、JOOX(joox)、哔哩哔哩(bili)、5sing(5sing);有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr);Apple Music(apple,仅 preview 试听)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单 - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单、专辑、歌手、链接解析(歌单/专辑/歌曲网页链接)
- 有声平台(xm/me/qt/lr)与 Apple Music(仅试听)不参与聚合搜索与跨平台匹配,仅支持 source 直查
## 平台能力速查
| 平台 | 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(登录解锁无损) | ✅ | ✅ | — | — | — | 扫码 |
| 咪咕音乐 | `mg` | ✅ | 64k/128k/320k/flac/flac24bit | ✅ | ✅ | ✅ 搜索/详情/分类 | ✅ 搜索/详情 | ✅ 歌曲/专辑/歌单 | cookie(解锁高档位) |
| 千千音乐 | `bd` | ✅ | 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` 等新增能力位)。
## 快速开始 ## 快速开始
@@ -26,26 +49,42 @@ pnpm typecheck # 仅类型检查,不运行
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) | | `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | | `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `GET /stream/:source/:id` | 加密流解密代理(source 用 path 段区分:`qs`=汽水解密流、`mg`=咪咕 Z3D/I3D 加密流)。取流响应返回相对地址时使用,服务端下载上游密流并在本地解密回明文音频 |
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | | `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
| `GET /pic/:source/:id` | 获取封面 | | `GET /pic/:source/:id` | 获取封面 |
| `GET /boards/:source` | 榜单列表 | | `GET /boards/:source` | 榜单列表 |
| `GET /boards/:source/:boardId` | 榜单详情 | | `GET /boards/:source/:boardId[/:page]` | 榜单详情(分页 path 段,第 1 页省略) |
| `GET /hot-search/:source` | 热搜列表 | | `GET /hot-search/:source` | 热搜列表 |
| `GET /song-list/:source?page=1` | 歌单列表 | | `GET /song-list/:source[/:page]` | 歌单列表(分页 path 段) |
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) | | `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
| `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) | | `GET /singer/:source/:id/songs[/:page]?limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /singer/:source/:id/albums[/:page]?limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx) | | `GET /album/:source/:id[/:page]` | 专辑详情与曲目(分页为 path 段,第 1 页省略)。支持 kg/kw/tx/wy/xm/me/qt/lr/mg/bd/joox/apple |
| `GET /album/:source/search/:keyword` | 专辑搜索(kg/kw/tx/wy/mg/bd/joox/apple) |
| `GET /playlist/:source/search/:keyword` | 歌单搜索(kg/kw/tx/wy/mg/bd/joox/bili/5sing/apple) |
| `GET /playlist/:source/categories` | 歌单分类(kw/kg/tx/wy/mg/bd/joox/bili/5sing/apple) |
| `GET /playlist/:source/categories/:categoryId` | 分类下的歌单(同上) |
| `GET /playlist/:source/:id[/:page]?limit=` | 歌单详情与曲目(kw/kg/tx/wy/mg/bd/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/bd/mg(其中 bili、5sing 无 album 端点) |
| `GET /user-playlist/:source` | 个人歌单(仅 tx/wy 有完整实现;需登录,未登录或平台不支持返回 404/502) |
| `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) | | `POST /login/wy/qr` → `POST /login/wy/qr/check` | 网易云扫码登录(扫码后轮询完成登录) |
| `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) | | `POST /login/wy/cellphone` | 网易云手机号登录(password 或 captcha) |
| `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) | | `POST /login/kg/qr` → `POST /login/kg/qr/check` | 酷狗扫码登录(扫码后轮询完成登录) |
| `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 | | `POST /login/kg/captcha` → `POST /login/kg/cellphone` | 酷狗手机验证码登录 |
| `POST /login/kg/password` | 酷狗账号密码登录(触发 20028 风控时返回 needVerify 挑战,配合 `/login/kg/verify` 提交图形/短信验证后重试) |
| `POST /login/kg/verify` | 提交酷狗风控验证结果(verifycode 为图形验证凭证或短信码) |
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) | | `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) | | `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) | | `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
| `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(用**汽水音乐 App** 扫一扫——同官方 PC 客户端文案;抖音 App 扫可能不完成确认。登录后解锁完整曲目与无损取流) |
| `POST /login/xm` | 绑定喜马拉雅 cookie(www.ximalaya.com 复制,须含 `1&_token`,解锁付费/已购内容取流) |
| `POST /login/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 /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -58,11 +97,54 @@ pnpm typecheck # 仅类型检查,不运行
## 歌曲 ID 约定 ## 歌曲 ID 约定
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
- **kg**:`hash`(搜索结果的 `hash` 字段;当前环境 kg 取流接口被服务端限制,URL 获取可能失败) - **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
- **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目
- **xm**:track id(搜索/专辑结果的 `songmid` 字段);匿名可取免费曲目(64k/128k 有声档位),付费/VIP 内容 `POST /login/xm` 绑定 cookie 后取流
- **me**:声音 id(搜索结果的 `songmid` 字段);走 App API 签名通道取免签名明文直链(m4a),匿名可取免费声音,付费内容 `POST /login/me` 绑定 token 后取流
- **qt**:`<channelId>:<programId>` 复合 id(/album/qt 节目列表产出);audiostream 签名直链(302 到华为云 CDN),匿名可取免费节目
- **lr**:`<bookId>:<section>` 复合 id(/album/lr 章节列表产出);取流必须 `POST /login/lr` 登录(免费章节同样要求登录态)
- **mg**:`<contentId>|<resourceType>|<formatType>` 三元组(搜索结果/专辑/歌单产出);取流走 listen-url 多档候选链 + 逐档 Range 探测,命中可访问的最高档(匿名实测可出 flac24bit 真无损),Z3D/I3D 加密流自动改走 `/stream/mg` 解密代理
- **bd**: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` 查询参数。
## 调用鉴权与限流
### 调用秘钥(AUTH_KEY)
设置环境变量 `AUTH_KEY` 后,**全部路径**(含文档页、健康检查、不存在的路径)都要求调用秘钥——未授权请求统一返回 401,不暴露 404/401 差异,防止接口被扫描探测。未设置则不鉴权。
```
X-Server-Key: <AUTH_KEY> # 统一验证头(全部路径)
?exp=&sig= # 仅 /stream/* 流代理:短时签名(/url 返回的流地址自动附带)
```
流代理场景(播放器/下载器直接 GET、无法带自定义头)用**短时签名**而非裸秘钥:`/url` 返回的流地址自动附带 `?exp=&sig=`(HMAC 派生,10 分钟有效)——签名泄露仅能在时效窗口内拉当条流,无法反推秘钥或调用其它接口;能带头的客户端仍可对 /stream/* 使用 X-Server-Key。
### 限流(RATE_LIMIT)
per-IP 滑动窗口限流,`RATE_LIMIT` 为每 IP 每分钟允许的请求数(默认 120,设 0 关闭)。超限返回 429 + Retry-After;鉴权失败的请求同样计数(防秘钥爆破)。
## 汽水 PC 签名(可选增强)
汽水(`qs`)的 PC 通道(搜索优先走、取流降级走)依赖 `X-Helios`/`X-Medusa` 两个加密签名头,按优先级两种供给方式;都没有时 PC 通道自动关闭,只走安卓自签通道(匿名搜索/试听不受影响):
1. **签名服务器**:环境变量 `QS_SIGN_SERVER`(如 `http://127.0.0.1:8899`,可选 `QS_SIGN_TOKEN` 配 Bearer 鉴权)。契约兼容独立仓库 **qs-signer**(`gitea.4i.hk/hhhge/qs-signer`,本地与 MusicServer 同级)的两个服务——`server.mjs`(Wine + bdms.node)与 `mssdk-sign`(metasecml.dll 独立进程,见其 README),跨平台部署无需在本机安装官方客户端。
2. **固定值**:无签名服务器时,两个加密头直接用抓包固定值,写入 `data/qs-sign.json`(已 gitignore):
```json
{ "helios": "…", "medusa": "…", "device_id": "…", "install_id": "…" }
```
`device_id`/`install_id` 必须与抓包时同一套(签名与设备绑定,会自动对齐 URL 参数);也可用环境变量 `QS_HELIOS`/`QS_MEDUSA`(配套 `QS_SIGN_DEVICE_ID`/`QS_SIGN_INSTALL_ID`)。固定值时效自负,被风控时搜索/取流自动回落安卓通道。
## 外部音源 ## 外部音源
设置 `SOURCE_ENABLED=1` 后取流切换到外部音源(默认未设置时走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。 设置 `SOURCE_ENABLED=1` 后取流优先走外部音源(默认未设置时直接走 SDK 官方直连接口;是否启用由服务端在 `src/server.ts` 决策)。两种脚本都放 `sources/` 目录(不入库),按文件名混排成候选链:取流失败自动切换下一个,某平台连续失败 3 次的脚本降权冷却 5 分钟;整条音源链失败后回落 SDK 直连、再失败走酷我兜底(见上文取流链)。音源候选链只覆盖 kw/kg/tx/wy(音源脚本协议无 qs 平台,`/url/qs` 始终走 SDK)。音源模式下登录态不参与取流,登录自动续期不会启动(手动 `POST /login/refresh` 仍可用)。详见 [docs/source-ivm.md](docs/source-ivm.md) 与 [docs/source-simple.md](docs/source-simple.md)。
**社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。 **社区音源脚本(`.js`)** —— 通用格式,isolated-vm 硬隔离沙箱执行(详见 `src/source-ivm.ts` 头注释),文件名排序即优先级;`SOURCE_SCRIPT_PATH` 可改为指定目录外单脚本,`SOURCE_LOG=0` 关闭请求日志,`SOURCE_ALLOW_ALL_HTTP=1` 放开 HTTP 白名单(调试用)。
@@ -89,24 +171,32 @@ export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码)
server.ts HTTP 服务器入口(pnpm start 实际启动文件) server.ts HTTP 服务器入口(pnpm start 实际启动文件)
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/token、tx 扫码/cookie) routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 汽水扫码、
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流重定向到 sources/ 脚本) bili 扫码、xm/me cookie 绑定、lr 账密)
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK)
source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱) source-ivm.ts 社区音源脚本加载器(isolated-vm 硬隔离沙箱)
source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载) source-simple.ts 简单 API 音源脚本加载器(.mjs 进程内直载)
http/ http/
api-error.ts 业务错误类型(404/502 等 → 统一 JSON 响应) 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/ openapi/
components.ts OpenAPI 共享 schema(与路由校验同源) components.ts OpenAPI 共享 schema(与路由校验同源)
ui.ts ReDoc 页面模板 ui.ts ReDoc 页面模板
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy 四平台) sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;15 个平台)
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts) index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy) apis/ 各平台官方 URL 直连取流实现(15 平台)
auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token) auth/ 登录态与登录协议(store.ts 持久化凭据到 data/auth.json;wy.ts 扫码/手机号、kg.ts 扫码/短信/token/续期、tx.ts 扫码/cookie/续期)
common/ SDK 基础设施 common/ SDK 基础设施
request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) request.ts HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压)
utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等) utils.ts 工具函数(sizeFormate/formatPlayTime/dateFormat 等)
message.ts 请求错误消息 message.ts 请求错误消息
kg/ kw/ tx/ wy/ 平台模块(搜索/歌词/榜单/歌单等 + 各自的歌词解码器) 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/ test/
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口) smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
source-simple-test.ts 简单 API 音源脚本加载器测试(离线) source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
@@ -116,6 +206,12 @@ test/
## 已知限制 ## 已知限制
- **kg 取流**:酷狗当前环境下 `wwwapi`/`trackercdn`/`m.kugou.com` 等取流接口均被服务端风控(err_code 30020/20010),播放地址获取可能失败;歌词、搜索、榜单正常。 - **kg 取流**:匿名仅能获取部分免费资源,VIP/付费歌需 `/login/kg` 登录(扫码/短信/token 均可)后取流,成功响应解析见 `sdk/apis/kg.ts` 头注释。
- **kw flac**:`antiserver` 接口对 flac 返回 `refuse`,高音质会回退官方接口(需要 csrf token,token 失效时自动重试)。 - **kw 取流**:主通道 mobi.s 严格校验「返回 bitrate == 请求档位且为明文格式」,档位缺失时沿降级链换档(flac24bit→flac→320k→128k);web playUrl 回退通道匿名恒 128k。
- **qs 取流**:匿名仅 30s 试听;完整曲目需 `/login/qs/qr` 用**汽水音乐 App** 扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
- **mg 取流**:匿名 PQ(128k) 永远可用,但候选链会优先探测更高档位——多数曲目实际能匿名拿到 SQ/ZQ 真无损直链(上游同款 tone 路径重写 + 逐档探测);Z3D/I3D 加密候选经 `/stream/mg` 解密后同样可播。HQ/SQ 返回「登录后免费试听」时自动降级到可用的最高档。
- **apple 取流**:只有 preview 试听片段(约 30–90s,AAC/m4a),返回的 `type` 固定为 `preview`,不要当作完整曲。
- **bili 取流**:匿名最高 192k;flac/Hi-Res 需 `/login/bili/qr` 登录大会员账号。CDN 有约 1 小时的 `deadline` 签名,取流结果不宜缓存过久。
- **5sing 歌单详情**:依赖 5sing 网页(`5sing.kugou.com/.../dj/...html`)解析曲目;2026-10-02 实测该站点网页层整体 502(搜索/取流/歌词等手机端 API 不受影响)。上游同样依赖该页面,待站点恢复即自动可用。
- **link 解析**:`/parse/*` 走各平台网页链接解析(需上游有对应实现);未实现的平台返回 404。
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。 - 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
+93 -57
View File
@@ -1,26 +1,47 @@
# MusicServer 聚合音乐 API # MusicServer 聚合音乐 API
多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)。 多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析、登录。
> 本文档为静态速查版。交互式文档以服务器实际输出为准: 共 15 个平台:`kw`(酷我)/ `kg`(酷狗)/ `tx`(QQ音乐)/ `wy`(网易云)/ `qs`(汽水)/ `mg`(咪咕)/
`bd`(千千)/ `joox` / `bili`(哔哩哔哩)/ `5sing` / `apple`(Apple Music,仅试听),
以及有声平台 `xm`(喜马拉雅)/ `me`(猫耳FM)/ `qt`(蜻蜓FM)/ `lr`(懒人听书)。
> **本文档是人工速查版,可能滞后于代码。**
> 接口与参数以服务器自动生成的规范为准(路由 schema 同源,永远与实现同步):
> >
> - **Swagger UI**:`http://localhost:16688/docs`(可直接调试) > - **Swagger UI**:`/docs`(可直接调试)
> - **ReDoc**:`http://localhost:16688/redoc` > - **ReDoc**:`/redoc`
> - **OpenAPI 3.1**:`http://localhost:16688/openapi.json`(可生成各语言客户端) > - **OpenAPI 3.1**:`/openapi.json`(可生成各语言客户端)
> - **平台能力位**:`GET /sources` —— 各接口具体支持哪些平台,一律以它的能力位为准,本文不再硬列
## 约定 ## 约定
- 默认端口 **16688**(可用 `PORT` 环境变量修改)。 - 默认端口 **16688**(`PORT` 环境变量可改)。
- 仅支持 `GET` 方法;服务器支持 CORS(`Access-Control-Allow-Origin: *`)。 - **全部接口为路径参数风格**,分页走 path 段且第 1 页可省略:`/search/kw/稻香`、`/search/kw/稻香/2`、
`/album/kg/123/2`。查询参数只用于修饰(`?quality=`、`?limit=`、`?singer=` 等)。
- 默认返回 `Access-Control-Allow-Origin: *`,可直接在浏览器/前端调用。
- **成功**:`200` + `{"code": 0, "data": ...}` - **成功**:`200` + `{"code": 0, "data": ...}`
- **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}` - **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}`
- `422` 参数校验失败(缺失、非法音质、页码越界等,FastAPI 风格)
- `404` 接口不存在 / 平台不支持该能力 | 状态码 | 含义 |
- `405` 方法不允许(响应带 `Allow` 头) | --- | --- |
- `502` 上游平台请求失败 | `422` | 参数校验失败(Fastify/Ajv,FastAPI 风格) |
- `500` 服务器内部错误 | `404` | 接口不存在,或平台不支持该能力 |
- 资源类接口统一为**路径参数风格**;`/search`、`/find` 搜索类接口使用 query 参数。 | `405` | 方法不允许(响应带 `Allow` 头) |
- 平台歌曲 id 形态:kw/tx/wy 用 `songmid`,kg 用 `hash`。 | `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、`mg` 的三元组、
`qt`/`lr` 的复合 id、`bili` 的 `<bvid>|<cid>` 等)详见 README「歌曲 ID 约定」。
复合 id 含 `|` / `:` 时在 URL 中需编码(`|` → `%7C`)。
## 接口速查 ## 接口速查
@@ -29,70 +50,88 @@
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/health` | 健康检查(status / uptime / 平台数) | | GET | `/health` | 健康检查(status / uptime / 平台数) |
| GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList/singer/album) | | GET | `/sources` | 平台列表:音质与全部能力位(**判断某接口支持哪些平台的唯一依据**) |
| GET | `/` | 302 重定向到 `/docs` | | GET | `/` | 302 重定向到 `/docs` |
| GET | `/docs` | Swagger UI 文档页 | | GET | `/docs` · `/redoc` · `/openapi.json` | Swagger UI / ReDoc / OpenAPI 3.1 规范 |
| GET | `/redoc` | ReDoc 阅读版文档 |
| GET | `/openapi.json` | OpenAPI 3.1 规范文档 |
### 搜索 ### 搜索
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台,失败的平台跳过 | | GET | `/search/:source/:keyword[/:page]` | 搜索歌曲;`source` 为平台 id 或 `all`(聚合,自带 `noAggregate` 的平台不参与)。`?singer=` 附加歌手、`?limit=` 每页条数 |
| GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 | | GET | `/find/:source/:keyword` | 精确匹配搜索;`?singer=` `?albumName=` `?interval=` 辅助匹配,`source=all` 时 `?excludeSource=` 跳过平台 |
### 歌曲资源 ### 歌曲资源
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/url/:source/:id` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) | | GET | `/url/:source/:id` | 播放地址;`?quality=`(64k/128k/320k/flac/flac24bit,默认 128k)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(响应 `via` 标记实际命中通道) |
| GET | `/lyric/:source/:id` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) | | GET | `/stream/:source/:id` | 加密流解密代理,`source` 用 path 段区分(`qs` 汽水解密流 / `mg` 咪咕 Z3D 加密流)。供 `/url` 返回的相对地址使用,不接受自行构造 |
| GET | `/pic/:source/:id` | 封面;可带 `albumId`(kg 需要) | | GET | `/lyric/:source/:id` | 歌词;可带 `?name=` `?interval=`(部分平台搜索歌词时需要) |
| GET | `/pic/:source/:id` | 封面;可带 `?albumId=`(kg 需要) |
### 榜单 ### 榜单 / 歌单 / 热搜
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/boards/:source` | 榜单列表 | | 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 | `/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` | 歌手信息(name/desc/avatar 与歌曲/专辑数量) |
| GET | `/singer/:source/:id/songs` | 歌手歌曲列表;可带 `page`/`limit`(默认 20)。支持 kg/tx/wy | | GET | `/singer/:source/:id/songs[/:page]` | 歌手歌曲列表;`?limit=` |
| GET | `/singer/:source/:id/albums` | 歌手专辑列表;可带 `page`/`limit`。支持 kg/tx/wy | | GET | `/singer/:source/:id/albums[/:page]` | 歌手专辑列表;`?limit=` |
| GET | `/album/:source/:id` | 专辑详情与曲目列表;可带 `page`/`limit`(limit 仅 kg 生效,kw 单页固定 1000 条)。支持 kg/kw/tx;tx 的专辑名/作者由曲目数据合成(desc 为空) | | 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` → `/login/wy/qr/check` | 网易云扫码(`check` body 传 `{"key": "..."}`) |
| POST | `/login/wy/qr/check` | 轮询扫码状态,body 传 `{ "key": "..." }`。返回 `code`:800 过期 / 801 等待 / 802 已扫码 / 803 登录成功(自动保存凭据) | | POST | `/login/wy/cellphone` | 网易云手机号登录(`password` 明文或 `captcha`) |
| POST | `/login/wy/cellphone` | 网易云手机号登录;body 传 `phone` + `password`(明文,服务端 MD5 后提交)或 `captcha`,可带 `countrycode`(默认 86) | | DELETE | `/login/wy` | 退出网易云 |
| DELETE | `/login/wy` | 退出网易云登录 | | POST | `/login/kg/qr` → `/login/kg/qr/check` | 酷狗扫码 |
| POST | `/login/kg` | 绑定酷狗 token;body 传 `{ "token": "...", "userid": ... }`(App 抓包获取),服务端向用户中心验证后存储 | | POST | `/login/kg/captcha` → `/login/kg/cellphone` | 酷狗短信验证码登录 |
| DELETE | `/login/kg` | 退出酷狗登录 | | POST | `/login/kg/password` | 酷狗账密登录(命中风控返回挑战,配合 `/login/kg/verify` 重试) |
| POST | `/login/tx` | 绑定 QQ 音乐 cookie;body 传 `{ "cookie": "..." }`(需含 `uin` 与 `qm_keyst` 字段)。可选附 App 抓包的 `openId`/`accessToken`/`refreshToken`/`refreshKey` 供自动续期 | | POST | `/login/kg` | 绑定酷狗 token(App 抓包) |
| DELETE | `/login/tx` | 退出 QQ 音乐登录 | | DELETE | `/login/kg` | 退出酷狗 |
| POST | `/login/refresh` | 手动触发 kg/tx 登录态续期(与自动任务同一逻辑;未登录平台跳过,单平台失败不影响其他) | | POST | `/login/tx/qr` → `/login/tx/qr/check` | QQ 音乐扫码 |
| GET | `/login/status` | 登录状态(wy 实时校验 / kg、tx 已存凭据,含 `vipType` 与最近更新时间) | | 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`(不入库),登录后取流与平台请求自动携带凭据。 - 凭据持久化于 `data/auth.json`(不入库,文件权限 0600),登录后取流与平台请求自动携带。
- **登录态自动续期**:服务器启动 10s 后自动对 kg/tx 执行一次续期,此后每 24h 一次(kg 走 `login_by_token` AES/RSA 协议换新 token;tx 走 `music.login.LoginServer`,仅浏览器 cookie 时可能被网关拒绝,需在 `/login/tx` 附 App 抓包的完整凭据才可靠),结果输出到控制台日志。 - 自动续期:启动 10s 后对 kg/tx 执行一次,此后每 24h 一次(音源模式下不参与取流,自动续期不启动)。
- VIP 歌曲完整播放地址暂不在支持范围;登录态目前用于提升请求稳定性与为后续能力预留。
## 响应示例 ## 响应示例
@@ -103,15 +142,12 @@
// GET /lyric/kw/228908 // GET /lyric/kw/228908
{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "yrc": "" } } { "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "yrc": "" } }
// 参数缺失 // 参数非法 → 422
// 422 { "code": 422, "msg": "params/source must be equal to one of the allowed values", "detail": [...] }
{ "code": 422, "msg": "querystring must have required property 'name'", "detail": [...] }
// 平台不支持该能力 // 平台不支持该能力 → 404
// 404
{ "code": 404, "msg": "不支持的平台: xx" } { "code": 404, "msg": "不支持的平台: xx" }
// 方法不允许 // 方法不允许 → 405 + Allow 头
// 405 + Allow: GET { "code": 405, "msg": "方法 POST 不允许,请使用 GET, HEAD" }
{ "code": 405, "msg": "方法 POST 不允许,请使用 GET" }
``` ```
+2 -2
View File
@@ -8,8 +8,8 @@
"scripts": { "scripts": {
"typecheck": "tsc", "typecheck": "tsc",
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node --watch-path=./src --watch-path=./sdk --watch-path=./sources --watch --watch-preserve-output src/server.ts", "dev": "node scripts/dev.mjs",
"test": "pnpm run typecheck && node test/source-simple-test.ts && node test/smoke-test.ts" "test": "pnpm run typecheck && node test/source-simple-test.ts && node test/bili-cdn-test.ts && node test/smoke-test.ts"
}, },
"dependencies": { "dependencies": {
"@fastify/cors": "^11.3.0", "@fastify/cors": "^11.3.0",
+2
View File
@@ -1,2 +1,4 @@
# isolated-vm 的原生构建脚本需显式放行(pnpm 12 要求),
# 否则加载失败会降级为「未安装 isolated-vm」,社区 .js 音源脚本被静默跳过。
allowBuilds: allowBuilds:
isolated-vm: true isolated-vm: true
+29
View File
@@ -0,0 +1,29 @@
// pnpm dev 的包装:node --watch-path 指向不存在的目录会直接 ENOENT 崩溃。
// sources/ 不入库(第三方音源脚本,gitignore 整目录),新 clone 的仓库里没有它,
// 所以这里只为实际存在的目录注册 watch。
import { existsSync } from 'node:fs'
import { spawn } from 'node:child_process'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const watchTargets = ['src', 'sdk', 'sources']
.map((dir) => join(root, dir))
.filter((dir) => existsSync(dir))
const child = spawn(
process.execPath,
[
...watchTargets.flatMap((dir) => ['--watch-path', dir]),
'--watch',
'--watch-preserve-output',
join(root, 'src/server.ts'),
],
{ stdio: 'inherit', cwd: root },
)
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal)
else process.exit(code ?? 1)
})
+50
View File
@@ -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
+38
View File
@@ -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 }
+114
View File
@@ -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)
+56
View File
@@ -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 }
+18
View File
@@ -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 }
+251
View File
@@ -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,
}
+188
View File
@@ -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 '../common/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] ?? ''
+53
View File
@@ -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 }
+72
View File
@@ -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 }
+73
View File
@@ -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 '../bd/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 }
+235
View File
@@ -0,0 +1,235 @@
// 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(上游无此参数意识,移植补的最小档位选择)。
//
// CDN 选址(2026-10-04 实测定稿):playurl 的 baseUrl 大概率落在 PCDN——京东云无线宝 mcdn.bilivideo.cn:8082、
// 迅雷 edge.mountaintoys.cn:4483、节点之家 szbdyd.com、派欧云 nexusedgeio.com——单节点、丢包严重,是点歌卡死的根源;
// 正规 CDN 为 upos-*.bilivideo.com(腾讯/金山/白山/华为)与 upos-*.akamaized.net(Akamai,境外),
// cn-*.bilivideo.com 是 B站自建/地区 CDN(同省同运营商单节点,无稳定性问题)。
// 因此候选集 = 命中档位节点的 baseUrl + backupUrl,按 host 分级排序(upos/akamaized > bilivideo.com 系 > PCDN),
// 逐个探测、命中即返回;全部失败时回退分级后的最优候选,客户端仍可自行跟跳。
// 参考 Referer/UA 必须携带,否则 CDN 可能 403;CDN 直链自带签名参数,不带也不应带 Cookie(避免登录态泄给第三方 PCDN 域名)。
import { httpFetch } from '../common/request.ts'
import { 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',
}
// CDN 探测预算:单跳最长 8s,全部候选合计不超过 20s(超时即回退最优候选,不再无限等)
const PROBE_HOP_TIMEOUT = 8000
const PROBE_TOTAL_BUDGET = 20000
const PROBE_MAX_CANDIDATES = 3
let vipCache: { value: boolean, at: number } | null = null
/** 登录态清空/更新后调用,丢弃大会员判断缓存 */
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
}
}
/** CDN host 分级:0=正规 CDN(upos-*.bilivideo.com / upos-*.akamaized.net,多地节点)
* 1=B站系(cn-* 自建地区 CDN 及其它 bilivideo.com) 2=PCDN(mcdn.bilivideo.cn、mountaintoys、szbdyd 等第三方) */
const hostRank = (url: string): number => {
let host = ''
try { host = new URL(url).hostname.toLowerCase() } catch { return 2 }
if (host.endsWith('.akamaized.net')) return 0
if (host.endsWith('.bilivideo.com')) return host.startsWith('upos-') ? 0 : 1
return 2
}
/** 节点地址收集:baseUrl + backupUrl(去重) */
const nodeUrls = (node: any): string[] => {
const backups = [
...(Array.isArray(node?.backupUrl) ? node.backupUrl : []),
...(Array.isArray(node?.backup_url) ? node.backup_url : []),
]
return [node?.baseUrl, ...backups].filter(Boolean).map(String)
}
/** 候选排序:host 分级升序,同级保持原始顺序(baseUrl 官方序优先) */
const rankCandidates = (urls: string[]): string[] => {
const seen = new Set<string>()
const uniq: string[] = []
for (const url of urls) {
const u = String(url || '')
if (!u || seen.has(u)) continue
seen.add(u)
uniq.push(u)
}
return uniq
.map((u, i) => ({ u, i, rank: hostRank(u) }))
.sort((a, b) => a.rank - b.rank || a.i - b.i)
.map(x => x.u)
}
const pickAudio = (data: any, type: string): { id: number, label: string, urls: string[] } | null => {
const dash = data?.dash ?? {}
const flac = dash?.flac?.audio
const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? flac : null
const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl)
const standards: any[] = (dash?.audio ?? [])
.filter((a: any) => a?.baseUrl)
.sort((a: any, b: any) => Number(b.id) - Number(a.id))
let node: any = null
let label = ''
if (type === '64k') {
const low = standards.find(a => Number(a.id) === 30216)
if (low) { node = low; label = QUALITY_LABEL[30216] }
}
// 上游优先级:Hi-Res FLAC → Dolby → 最高标准档
if (!node && hires) { node = hires; label = QUALITY_LABEL[30251] }
if (!node && dolby) { node = dolby; label = QUALITY_LABEL[30250] }
if (!node && standards.length) {
node = standards[0]
label = QUALITY_LABEL[Number(standards[0].id)] ?? `audio-${standards[0].id}`
}
if (node) return { id: Number(node.id) || 0, label, urls: rankCandidates(nodeUrls(node)) }
const durlNode = data?.durl?.[0]
if (durlNode?.url) return { id: 0, label: 'durl', urls: rankCandidates(nodeUrls(durlNode)) }
return null
}
/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, urls };urls 已按 CDN 分级排序,[0] 为最优) */
export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, urls: 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 || !picked.urls.length) throw new Error('B站未返回可用音频流')
return { type: picked.label, urls: picked.urls }
}
/**
* 探测单个候选并跟随 302:GET + Range: bytes=0-0,读到响应头即断开 body。
* HEAD 会被 CDN 404 不能用;也不能等 body 自然结束——CDN 忽略 Range 返回 200 全量时,
* 会把整个音频文件下载缓冲完才返回(点歌就卡死在这)。
* 命中 2xx/206 返回该地址;4xx/5xx/超时/网络错误抛错,由调用方换下一候选。
*/
const resolveUrl = async(url: string, tracker: { current: any, cancelled?: boolean }, deadline: number): Promise<string> => {
let current = String(url)
for (let hop = 0; hop < 3; hop++) {
const remaining = deadline - Date.now()
if (remaining <= 0) throw new Error('CDN 探测超时(总预算耗尽)')
const controller = new AbortController()
tracker.current = controller
const timer = setTimeout(() => controller.abort(new Error('CDN 探测超时')), Math.min(PROBE_HOP_TIMEOUT, remaining))
timer.unref?.()
let resp: Response
try {
resp = await fetch(current, {
method: 'get',
headers: { ...biliHeaders(), Range: 'bytes=0-0' },
redirect: 'manual',
signal: controller.signal,
})
} catch (err: any) {
if (tracker.cancelled) { err.canceled = true; throw err }
throw err
} finally {
clearTimeout(timer)
}
// 拿到响应头即断开,不消费 body
await resp.body?.cancel().catch(() => {})
const status = resp.status
const location = resp.headers.get('location')
if (status >= 300 && status < 400 && location) {
try {
current = new URL(location, current).href
continue
} catch {
return current
}
}
if (status >= 200 && status < 300) return current
if (status >= 400) throw new Error(`CDN 返回 HTTP ${status}`)
return current
}
return current
}
/**
* 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp }
* promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位)
* songInfo.songmid 约定 `<bvid>|<cid>`;仅给 bvid 时回源 view 取首P cid。
* CDN 候选按 host 分级探测(upos/cn 正规源优先),全败回退最优候选(fail-soft,客户端可自行跟跳)。
*/
export const getMusicUrl = (songInfo: any, type: string) => {
const tracker: { current: any, cancelled?: boolean } = { current: null }
const requestObj: any = {
cancelHttp() {
tracker.cancelled = true
try {
tracker.current?.abort?.(new Error('Canceled'))
} 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 deadline = Date.now() + PROBE_TOTAL_BUDGET
for (const candidate of info.urls.slice(0, PROBE_MAX_CANDIDATES)) {
try {
const finalUrl = await resolveUrl(candidate, tracker, deadline)
return { type: info.type, url: finalUrl }
} catch (err: any) {
if (err?.canceled || tracker.cancelled) throw new Error('canceled')
if (Date.now() >= deadline) break
}
}
// 探测全败(或预算耗尽):回退分级排序后的最优候选
return { type: info.type, url: info.urls[0] }
})()
return requestObj
}
export default { getMusicUrl }
+49
View File
@@ -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 '../common/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(/&amp;/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 }
+16 -6
View File
@@ -2,6 +2,8 @@
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名) // 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源, // 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high // VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
// 成功响应(status:1)播放地址在顶层 url: string[] 与 backupUrl: string[](2026-10 实测;
// 旧 data:[{url}] 结构仅作兼容兜底)。album_audio_id 传 NaN 会被网关拒答,必须归零。
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url } // promise resolve → { type, url }
@@ -15,6 +17,8 @@ const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
const APPID = 1005 const APPID = 1005
const CLIENTVER = 11430 const CLIENTVER = 11430
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' } const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality) // 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
@@ -38,14 +42,14 @@ export const getMusicUrl = (songInfo, type) => {
const params: Record<string, any> = { const params: Record<string, any> = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER, dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000), clienttime: Math.floor(Date.now() / 1000),
album_id: Number(songInfo.albumId ?? 0), album_id: num(songInfo.albumId),
area_code: 1, area_code: 1,
hash, hash,
ssa_flag: 'is_fromtrack', ssa_flag: 'is_fromtrack',
version: CLIENTVER, version: CLIENTVER,
page_id: 151369488, page_id: 151369488,
quality: qualityMap[type] ?? '128', quality: qualityMap[type] ?? '128',
album_audio_id: Number(songInfo.songmid ?? 0), album_audio_id: num(songInfo.songmid),
behavior: 'play', behavior: 'play',
pid: 2, pid: 2,
cmd: 26, cmd: 26,
@@ -75,12 +79,18 @@ export const getMusicUrl = (songInfo, type) => {
}).promise }).promise
const body = r.body && typeof r.body === 'object' ? r.body : {} const body = r.body && typeof r.body === 'object' ? r.body : {}
if (body.status !== 1 || !Array.isArray(body.data)) { if (body.status !== 1) {
throw new Error(body.error || (auth ? '获取失败' : '获取失败(该资源需要酷狗登录)')) throw new Error(body.error || (auth ? `获取失败(status ${body.status ?? '未知'})` : '获取失败(该资源需要酷狗登录)'))
} }
let url = ''
if (Array.isArray(body.url)) url = body.url[0] || ''
else if (Array.isArray(body.backupUrl)) url = body.backupUrl[0] || ''
else if (Array.isArray(body.data)) {
// 旧版 data:[{url, fallback}] 结构兜底
const item = body.data.find(d => d.url) ?? body.data[0] const item = body.data.find(d => d.url) ?? body.data[0]
const url = item?.url || item?.fallback url = item?.url || item?.fallback || ''
if (!url) throw new Error('获取失败') }
if (!url) throw new Error('获取失败(网关未返回播放地址)')
return { type, url } return { type, url }
})() })()
return requestObj return requestObj
+136 -73
View File
@@ -1,50 +1,132 @@
// 酷我音乐 URL 获取(直连) // 酷我音乐 URL 获取(直连)
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url } // promise resolve → { type, url }(type 为实际拿到的音质,请求档位缺失时沿降级链回退)
// //
// 取流方案(按序回退): // 取流通道(按优先级):
// 1. antiserver.kuwo.cn/anti.s(convert_url3 老接口,无需 token,128k/320k 有效, // 1) http://mobi.kuwo.cn/mobi.s convert_url_with_sign(主通道)
// 未登录时部分资源只返回试听片段) // source=kwplayer_ar_8.5.5.0_apk_keluze.apk 指纹 + user=0 匿名即可。
// 2. www.kuwo.cn/api/v1/www/music/playUrl(需 csrf token,token 失效自动重取重试一次) // 2026-10-01 实测(含整段 MPEG 帧逐帧校验,非仅看开头):
// // - 付费/VIP 歌匿名放行真音频:晴天(rid 228908) 拿到 2000k flac 52.83MB(269.7s,fLaC
// 2026-09-19 实测结论(勿轻易改链): // STREAMINFO 校验)与 320k mp3 10.29MB(10329 帧全程 320kbps/44100Hz 对齐),
// - mobi.kuwo.cn/mobi.s(DES 加密 query)已进防盗链蜜罐: // 均与搜索接口 N_MINFO 元数据的官方大小逐字节吻合;
// 任意 rid(含不存在的)都返回 200 与 MP3 地址,但内容是《防盗链打击音频》假文件, // - hi-res 也可用:起风了(rid 82239887) br=4000kflac → 88.2kHz flac 109.12MB;
// 且响应无法与真实响应区分,故不接入默认链路;DES 实现可参考 UnblockNeteaseMusic 上游 kwDES。 // - 请求档位不存在时会静默降级(2000kflac→48k aac、4000kflac→128k mp3)甚至升级到
// - playUrl 对新 token(Hm_Iuvt_* cookie)返回 "The request is illegal!"(要求签名), // mgg/mflac 加密格式,因此必须严格校验「返回 bitrate == 请求档位 且 格式为明文音频」,
// 首页也不再下发 kw_token,getToken 会失败——通道名存实亡,仅作最后回退。 // 不匹配视为未命中、沿降级链换档重试;
// - flac 及以上音质各匿名通道均要求 VIP 登录态;接入酷我登录 token 后可重估 mobi.s。 // - 此前「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 端算法,
// 参考 qyhqiu/kuwoMusicApi 的 secret.js)。token 会话级缓存,失效自动刷新重试一次。
// 限制:服务器无视 br 参数,匿名恒返回 128k MP3;VIP 歌明确报「该歌曲为付费内容」,
// 不吐假音频(作为 mobi 通道网络故障时的兜底)。
// - 写死 token(含旧 kw_token cookie)必报 "The request is illegal!",token 必须动态获取。
import { randomUUID } from 'crypto'
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
// 音质映射:128k → 128kmp3, 320k → 320kmp3, flac → flac(antiserver/mobi.s 用 mp3/flac 格式) const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
const qualityMap = { const MOBI_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36'
'128k': { br: '128kmp3', format: 'mp3' },
'320k': { br: '320kmp3', format: 'mp3' }, // 音质 → mobi.s 请求档位(bitrate 为响应必须精确命中的数值)
flac: { br: 'flac', format: 'flac' }, const qualityTiers: Record<string, { br: string, bitrate: number }> = {
flac24bit: { br: 'flac', format: 'flac' }, '128k': { br: '128kmp3', bitrate: 128 },
'320k': { br: '320kmp3', bitrate: 320 },
'flac': { br: '2000kflac', bitrate: 2000 },
'flac24bit': { br: '4000kflac', bitrate: 4000 },
} }
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality) // 档位缺失时的降级链(请求档位 → 逐级尝试)
export const supportQualitys = Object.keys(qualityMap) const fallbackChain: Record<string, string[]> = {
'flac24bit': ['flac', '320k', '128k'],
'flac': ['320k', '128k'],
'320k': ['128k'],
'128k': [],
}
let token = '' // 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播
let tokenPromise: Promise<string> | null = null 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']
/** /**
* 获取 kw csrf token(访问首页拿 cookie) * mobi.s convert_url_with_sign 取流
* 未命中(无 url / bitrate 不匹配 / 加密格式)resolve null,由调用方沿降级链换档
*/ */
const getToken = (retryNum = 0) => { const getUrlByMobi = (songmid: string, tier: { br: string, bitrate: number }): Promise<{ url: string } | null> => {
const requestObj = httpFetch(`http://mobi.kuwo.cn/mobi.s?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk&type=convert_url_with_sign&br=${tier.br}&rid=${songmid}`, {
method: 'get',
headers: {
'User-Agent': MOBI_UA,
'Accept-Language': 'zh-CN,zh;q=0.9',
},
timeout: 15000,
})
return requestObj.promise.then(({ statusCode, body }) => {
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: normalizeUrl(data.url) }
}).catch(() => null)
}
interface KwToken { name: string, value: string, secret: string }
let token: KwToken | null = null
let tokenPromise: Promise<KwToken> | null = null
/**
* 酷我 web 端 Secret 头哈希:t 为消息(cookie 值),e 为密钥(cookie 名)
*/
const kwSecret = (t: string, e: string): string => {
let digits = ''
for (let i = 0; i < e.length; i++) digits += e.charCodeAt(i).toString()
const r = Math.floor(digits.length / 5)
const o = parseInt(digits.charAt(r) + digits.charAt(2 * r) + digits.charAt(3 * r) + digits.charAt(4 * r) + digits.charAt(5 * r))
const l = Math.ceil(e.length / 2)
const c = 2 ** 31 - 1
if (o < 2) throw new Error('酷我 Secret 哈希失败')
let d = Math.round(1e9 * Math.random()) % 1e8
digits += d
while (digits.length > 10) digits = (parseInt(digits.substring(0, 10)) + parseInt(digits.substring(10, digits.length))).toString()
let n = (o * parseInt(digits) + l) % c
let out = ''
for (let i = 0; i < t.length; i++) {
const x = t.charCodeAt(i) ^ Math.floor(n / c * 255)
out += x < 16 ? `0${x.toString(16)}` : x.toString(16)
n = (o * n + l) % c
}
let hex = d.toString(16)
while (hex.length < 8) hex = `0${hex}`
return out + hex
}
/**
* 获取会话 token(访问首页,解析动态下发的 Hm_Iuvt_* cookie 名与值)
*/
const getToken = (retryNum = 0): Promise<KwToken> => {
if (token) return Promise.resolve(token) if (token) return Promise.resolve(token)
if (tokenPromise) return tokenPromise if (tokenPromise) return tokenPromise
tokenPromise = httpFetch('http://www.kuwo.cn/', { tokenPromise = httpFetch('https://www.kuwo.cn/', {
method: 'get', method: 'get',
headers: { 'User-Agent': UA },
timeout: 15000, timeout: 15000,
}).promise.then(({ headers }) => { }).promise.then(({ headers }) => {
tokenPromise = null tokenPromise = null
const match = ((headers['set-cookie'] as string[]) || []).join(';').match(/kw_token=(\w+)/) const cookies = ((headers['set-cookie'] as string[]) || []).map(c => {
if (!match) throw new Error('获取token失败') const i = c.indexOf('=')
token = match[1] return { name: c.slice(0, i).trim(), value: i >= 0 ? c.slice(i + 1).split(';')[0] : '' }
}).filter(c => /^Hm_Iuvt_\w+$/.test(c.name) && c.value)
if (!cookies.length) throw new Error('获取酷我token失败')
token = { name: cookies[0].name, value: cookies[0].value, secret: kwSecret(cookies[0].value, cookies[0].name) }
return token return token
}).catch(err => { }).catch(err => {
tokenPromise = null tokenPromise = null
@@ -55,64 +137,45 @@ const getToken = (retryNum = 0) => {
} }
/** /**
* 通过 antiserver 老接口获取(无需 token) * 通过官方 playUrl 接口获取(需 Secret 签名;匿名恒 128k)
* @param {string} rid MUSIC_xxx * @param t 本次请求使用的 token(显式传入,避免与并发刷新竞态)
* @param {string} br 128kmp3 / 320kmp3 / flac
* @param {string} format mp3 / flac
*/ */
const getUrlByAntiServer = (rid, br, format) => { const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => {
return httpFetch(`http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${rid}&format=${format}&response=url&br=${br}`, { const requestObj = { cancelHttp() {}, promise: null as any }
requestObj.promise = httpFetch(`https://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId=${randomUUID()}`, {
method: 'get', method: 'get',
headers: { headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', Cookie: `${t.name}=${t.value}`,
Secret: t.secret,
Referer: 'https://www.kuwo.cn/',
'User-Agent': UA,
}, },
timeout: 15000, timeout: 15000,
}).promise.then(({ statusCode, body }) => { }).promise.then(({ statusCode, body }) => {
if (statusCode != 200 || typeof body != 'object' || body.code != 200 || !body.url) { const msg: string = body?.msg || body?.message || ''
return Promise.reject(new Error(body?.msg || '获取失败')) if (statusCode != 200 || !body?.success || !body.data?.url) {
// 签名/token 失效:刷新后重试一次(VIP 拒绝等其他错误不重试)
if (retryNum == 0 && /illegal/i.test(msg)) {
token = null
return getToken().then(fresh => getUrlByPlayUrl(songmid, fresh, ++retryNum).promise)
} }
return body.url return Promise.reject(new Error(msg || '获取失败'))
})
}
/**
* 通过官方 playUrl 接口获取(需要 csrf token)
*/
const getUrlByPlayUrl = (songmid, br, retryNum = 0) => {
const requestObj = httpFetch(`http://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&br=${br}`, {
method: 'get',
headers: {
csrf: token,
cookie: `kw_token=${token}`,
Referer: 'http://www.kuwo.cn/',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
timeout: 15000,
})
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || body.code != 200) {
// token 失效时重新获取再试一次
if (token && /token|403|401/.test(body?.msg || '') && retryNum == 0) {
token = ''
return getUrlByPlayUrl(songmid, br, ++retryNum).promise
} }
return Promise.reject(new Error(body?.msg || '获取失败')) // 服务器无视 br,匿名实际只回 128k,如实上报
} return { type: '128k', url: normalizeUrl(body.data.url) }
if (!body.data?.url) return Promise.reject(new Error('获取失败'))
return body.data.url
}) })
return requestObj return requestObj
} }
export const getMusicUrl = (songInfo, type) => { export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any } const requestObj = { cancelHttp() {}, promise: null as any }
const { br, format } = qualityMap[type] || qualityMap['128k'] const start = qualityTiers[type] ? type : '128k'
const rid = `MUSIC_${songInfo.songmid}` const chain = [start, ...fallbackChain[start]]
// 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
requestObj.promise = getUrlByAntiServer(rid, br, format).catch(() => { requestObj.promise = chain.reduce(
// antiserver 无此音质(flac 常被拒)时回退官方接口 (p, tierType) => p.then(res => res ?? getUrlByMobi(songInfo.songmid, qualityTiers[tierType]).then(hit => hit && { type: tierType, url: hit.url })),
return getToken().then(() => getUrlByPlayUrl(songInfo.songmid, br).promise) Promise.resolve(null as any),
}).then(url => ({ type, url })) ).then(res => res ?? getToken().then(t => getUrlByPlayUrl(songInfo.songmid, t).promise))
return requestObj return requestObj
} }
+41
View File
@@ -0,0 +1,41 @@
// 懒人听书 URL 获取(web 通道 getPlayPath,需登录)
// 接口: https://m.lrts.me/ajax/getPlayPath?entityId=<bookId>&entityType=3&opType=1&sections=[<section>]&type=0
// 实测(2026-10-02):免费章节匿名也返回 status=2 "收费章节未购买"——必须携带 /login/lr 登录会话;
// 成功响应 list[0].path 为音频直链。
// songmid 为复合 id "<bookId>:<section>"(/album/lr 章节列表产出);纯 bookId 视为第一章
import { httpFetch } from '../common/request.ts'
import { getLrCookie } from '../lr/auth.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const PLAY_URL = 'https://m.lrts.me/ajax/getPlayPath'
export const supportQualitys = ['64k', '128k']
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
const [bookId, section = '1'] = songmid.split(':')
requestObj.promise = (async() => {
if (!bookId || !/^\d+$/.test(bookId)) throw new Error('懒人 id 需为 <bookId>[:<section>](见 /album/lr 章节列表)')
const cookie = getLrCookie()
if (!cookie) throw new Error('懒人听书取流需要登录(POST /login/lr 绑定账号密码)')
const { statusCode, body } = await httpFetch(
`${PLAY_URL}?entityId=${bookId}&entityType=3&opType=1&sections=[${section}]&type=0`,
{ headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/', Cookie: cookie }, timeout: 15000 },
).promise as any
const b = body ?? {}
if (statusCode != 200) throw new Error(`取流失败(HTTP ${statusCode})`)
const path = b?.list?.[0]?.path
if (!path) {
if (b.status === 2) throw new Error(b.msg || '收费章节未购买(需账号已购/会员)')
if (String(b.msg || '').includes('登录')) throw new Error('登录态已失效,请重新 POST /login/lr')
throw new Error(b.msg || '取流失败')
}
return { type: type === '128k' ? '128k' : '64k', url: String(path) }
})()
return requestObj
}
export default { getMusicUrl }
+68
View File
@@ -0,0 +1,68 @@
// 猫耳FM URL 获取(App API 通道)
// 链路:HMAC-SHA256(appsecret) 签名 → app.missevan.com/sound/sound → 免签名明文直链
// soundurl/soundurl_64 = 原文件(码率视源,常见 192k AAC)
// soundurl_128 = 128k m4a;soundurl_32 = 192k 变体(字段命名与实际码率错位,以实测为准)
// 实测(2026-10-02)匿名可取免费声音,直链首字节 ftyp M4A 明文可播;
// 付费声音需 /login/me 绑定含 token 的 cookie 后由服务端携带
// songmid 约定:裸 id = 声音 id(声音搜索/剧集列表产出);
// "d:<drama_id>" = 广播剧(剧搜索产出)——取流时自动选剧的首个免费集(预告/第一集)
// 统一接口约定:apis('me').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { getSoundInfo } from '../me/musicInfo.ts'
// 音质 → 字段优先级(原文件可能高于 128k,128k 档固定 m4a)
const QUALITY_FIELDS: Record<string, string[]> = {
'128k': ['soundurl_128', 'soundurl', 'soundurl_64', 'soundurl_32'],
'64k': ['soundurl_64', 'soundurl', 'soundurl_128', 'soundurl_32'],
}
export const supportQualitys = ['64k', '128k']
const pickUrl = (info: any, type: string) => {
const fields = QUALITY_FIELDS[type] ?? QUALITY_FIELDS['128k']
let url = ''
for (const field of fields) {
const v = info?.[field]
if (v && String(v).startsWith('http')) { url = String(v); break }
}
if (!url) throw new Error('无可用音频资源(付费内容需 POST /login/me 绑定 token)')
const hit128 = url.includes('-128k') || type === '128k'
return { type: hit128 ? '128k' : '64k', url }
}
/** 剧条目 "d:<drama_id>" → 首个免费集的 sound_id(getdrama episodes.episode[]) */
const resolveDramaFirstFree = async(dramaId: string): Promise<string> => {
const { httpFetch } = await import('../common/request.ts')
const { statusCode, body } = await httpFetch(`https://www.missevan.com/dramaapi/getdrama?drama_id=${dramaId}`, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
Accept: 'application/json',
Referer: `https://www.missevan.com/mdrama/${dramaId}`,
},
timeout: 15000,
}).promise as any
const episodes = (statusCode == 200 ? body?.info?.episodes?.episode : null) ?? []
if (!episodes.length) throw new Error('剧集列表为空或已下线')
const first = episodes.find((e: any) => Number(e.pay_type) === 0) ?? episodes[0]
return String(first.sound_id)
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
requestObj.promise = (async() => {
let soundId = songmid
if (songmid.startsWith('d:')) {
if (!/^\d+$/.test(songmid.slice(2))) throw new Error('剧 id 无效')
soundId = await resolveDramaFirstFree(songmid.slice(2))
} else if (!/^\d+$/.test(soundId)) {
throw new Error('me id 需为声音 id 或 d:<剧id>(见 /search?source=me 与 /album/me)')
}
const info = await getSoundInfo(soundId)
return pickUrl(info, type)
})()
return requestObj
}
export default { getMusicUrl }
+357
View File
@@ -0,0 +1,357 @@
// 咪咕取流 —— listen-url 多档位候选链 + 逐档探测(Range 0-63 验音频魔数/解密验证),
// 命中可访问的最高档位;匿名仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie。
// 上游对应:music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates /
// selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse)
import { httpFetch } from '../common/request.ts'
import {
AndroidChannel, AndroidUA, AndroidVersion, decryptZ3D, firstNonEmpty,
getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem,
} from '../mg/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
}
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/mg 代理) */
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 }
+189
View File
@@ -0,0 +1,189 @@
// 汽水音乐 URL 获取(直连)
// 两条取流通道:
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
// 登录态: 官方 PC 签名 X-Helios/X-Medusa(见 sdk/qs/mssdk.ts)→ /luna/pc/track_v2
// → 加密音频(cenc-aes-ctr)→ spade key 解密 → /stream/qs/:id 代理端点回明文流
// (相对地址,路由层负责补全 origin)
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(汽水音乐 APP 扫码 /login/qs/qr)。
// 登录请求失败自动降级匿名试听。
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }
import { httpFetch } from '../common/request.ts'
import { createHash, randomBytes } from 'node:crypto'
import musicInfo from '../qs/musicInfo.ts'
import { getCookie, getSessionId } from '../qs/passport.ts'
import { mssdkAvailable, signQishuiRequest, pcCommonParams, PC_UA } from '../qs/mssdk.ts'
import { decryptAudioBuffer, resolveAudioKey } from '../qs/audiodecrypt.ts'
import type { HttpResponse } from '../types.ts'
// 官方直连接口支持的音质:授权曲匿名只有 30 秒试听 128k(登录态取码率最高条目,324k/hi_res 视权益);
// 用户上传曲匿名即可整曲(实测 2026-10-02:DJ 版整曲 204.84s / 三档 68k・132k・260k)
export const supportQualitys = ['128k', '320k']
const TRACK_V2_URL = 'https://api.qishui.com/luna/pc/track_v2'
const STREAM_PREFIX = '/stream/qs/'
const STREAM_CACHE_TTL = 10 * 60_000
const STREAM_CACHE_MAX = 3
interface StreamEntry {
buffer: Buffer
contentType: string
bitrate: number
size: number
resolvedAt: number
}
// 已解密音频缓存(/url 解析一次,/stream 直接消费;LRU + TTL)
const streamCache = new Map<string, StreamEntry>()
const inflight = new Map<string, Promise<StreamEntry>>()
const cachePut = (trackId: string, entry: StreamEntry) => {
streamCache.delete(trackId) // 重新插入以刷新 LRU 顺序
streamCache.set(trackId, entry)
while (streamCache.size > STREAM_CACHE_MAX) {
streamCache.delete(streamCache.keys().next().value!)
}
}
/** 已缓存的明文音频(/stream/qs/:id 用;缓存未命中返回 null,由路由触发重解析) */
export const getCachedStream = (trackId: string): StreamEntry | null => {
const entry = streamCache.get(trackId)
if (!entry) return null
if (Date.now() - entry.resolvedAt > STREAM_CACHE_TTL) {
streamCache.delete(trackId)
return null
}
return entry
}
// 匿名通道:码率最高的未加密条目(带 PlayAuth = 加密,直接回 URL 不可播,跳过)
const pickPlainAudioUrl = (playInfoList: any): string | null => {
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl && !item.PlayAuth)
if (!entries.length) return null
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
return entries[0].MainPlayUrl
}
const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
const body = await musicInfo(trackId).promise
const playerInfoUrl = body?.track_player?.url_player_info
if (!playerInfoUrl) throw new Error('获取失败')
const { statusCode, body: playInfo } = await httpFetch(playerInfoUrl, { method: 'get', timeout: 15000 }).promise
const url = statusCode == 200 ? pickPlainAudioUrl(playInfo?.Result?.Data?.PlayInfoList) : null
if (!url) throw new Error('获取失败')
return url
}
// 登录态通道:官方 PC 客户端签名 X-Helios/X-Medusa(签名服务器或固定值,见 sdk/qs/mssdk.ts)
const pickBestEntry = (playInfoList: any) => {
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
if (!entries.length) return null
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
return entries[0]
}
const fetchTrackPlayer = async (url: string, headers: Record<string, string>, requestBody: string) => {
const { statusCode, body } = (await httpFetch(url, {
method: 'post',
headers,
body: requestBody,
timeout: 15000,
}).promise) as HttpResponse
const trackPlayer = (body as any)?.track_player
if (statusCode != 200 || !trackPlayer?.url_player_info) {
const errMsg = (body as any)?.status_info?.status_msg || (body as any)?.message || ''
throw new Error(`track_v2 获取失败: HTTP ${statusCode} ${errMsg}`.trim())
}
return trackPlayer as { url_player_info: string }
}
const TRACK_V2_BODY = (trackId: string) =>
JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
const fetchTrackPlayerByPc = async (trackId: string) => {
const sessionId = getSessionId()
if (!mssdkAvailable()) throw new Error('PC 签名不可用(16689 未探测到签名服务,可配置 QS_SIGN_SERVER 或 data/qs-sign.json,见 qs-signer 仓库)')
const url = `${TRACK_V2_URL}?${new URLSearchParams(pcCommonParams()).toString()}`
const requestBody = TRACK_V2_BODY(trackId)
const headers: Record<string, string> = {
'content-type': 'application/json; charset=utf-8',
'user-agent': PC_UA,
'x-luna-background-type': 'foreground',
'x-luna-is-background-req': '0',
'x-luna-is-local-user': '0',
'x-tt-trace-id': `00-${randomBytes(8).toString('hex')}-${randomBytes(8).toString('hex')}-01`,
'x-ss-stub': createHash('md5').update(requestBody).digest('hex').toUpperCase(),
...(sessionId ? { cookie: getCookie() || `sessionid=${sessionId}` } : {}),
}
const signed = await signQishuiRequest(url, headers, requestBody)
if (!signed) throw new Error('官方签名失败')
return fetchTrackPlayer(url, { ...headers, ...signed }, requestBody)
}
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
const trackPlayer = await fetchTrackPlayerByPc(trackId)
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
if (!entry) throw new Error('无可用音频资源')
const audioRes = await httpFetch(entry.MainPlayUrl, { method: 'get', timeout: 30000 }).promise
const encrypted = audioRes.raw
if (!encrypted?.length) throw new Error('音频下载失败')
let buffer: Buffer = encrypted
let contentType = 'audio/mp4'
if (entry.PlayAuth) {
const hexKey = resolveAudioKey({ spade_a: entry.PlayAuth })
const decrypted = decryptAudioBuffer(new Uint8Array(encrypted), hexKey)
buffer = Buffer.from(decrypted.buffer)
contentType = decrypted.content_type
}
const streamEntry: StreamEntry = {
buffer,
contentType,
bitrate: entry.Bitrate || 0,
size: buffer.length,
resolvedAt: Date.now(),
}
cachePut(trackId, streamEntry)
return streamEntry
}
/** 解析明文音频流(/stream/qs/:id 用;先查缓存,未命中重新解析) */
export const getStreamBuffer = async (trackId: string): Promise<StreamEntry> => {
const cached = getCachedStream(trackId)
if (cached) return cached
let work = inflight.get(trackId)
if (!work) {
work = resolveSignedStream(trackId).finally(() => inflight.delete(trackId))
inflight.set(trackId, work)
}
return work
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const trackId = String(songInfo.songmid ?? '')
const sessionId = getSessionId()
if (sessionId) {
// 登录态:签名 track_v2 + 服务端解密,/url 阶段完成解析(快速失败),/stream 直接消费缓存
requestObj.promise = getStreamBuffer(trackId)
.then(entry => {
console.log(`[qs/url] ${trackId} 解密流就绪 bitrate=${entry.bitrate} size=${entry.size}`)
return { type, url: `${STREAM_PREFIX}${trackId}` }
})
.catch(err => {
console.log(`[qs/url] ${trackId} 登录态取流失败,降级匿名试听: ${err.message}`)
return anonymousPreviewUrl(trackId).then(url => ({ type, url }))
})
return requestObj
}
// 匿名:未加密试听直链
requestObj.promise = anonymousPreviewUrl(trackId).then(url => ({ type, url }))
return requestObj
}
export default { getMusicUrl, getStreamBuffer, getCachedStream }
+34
View File
@@ -0,0 +1,34 @@
// 蜻蜓FM URL 获取(点播 audiostream 签名直链)
// 链路(官网 main.js 逆向,2026-10-02 实测):
// GET https://audio.qtfm.cn/audiostream/redirect/<channel>/<program>?access_token=&device_id=MOBILESITE&qingting_id=&t=<ms>&sign=<hex>
// sign = HMAC-MD5("7l8CZ)SgZgM_bkrw", path+query) —— 签名内容含完整 path 与 query(sign 自身除外)
// 匿名空 token 即可取免费节目;响应 302 到华为云签名 CDN(vohwod-sign.qtfm.cn,m4a/mp3)
// 直播电台另有 /live/<cid>/64k.mp3 + HMAC-MD5("Lwrpu$K5oP") 签名(lhttp-hw.qtfm.cn)
// songmid 为复合 id "<channelId>:<programId>"(/album/qt 列表产出)
import crypto from 'node:crypto'
const SIGN_KEY = '7l8CZ)SgZgM_bkrw'
const AUDIO_HOST = 'https://audio.qtfm.cn'
export const supportQualitys = ['64k', '128k']
export const buildProgramUrl = (channelId: string, programId: string) => {
const s = `/audiostream/redirect/${channelId}/${programId}?access_token=&device_id=MOBILESITE&qingting_id=&t=${Date.now()}`
const sign = crypto.createHmac('md5', SIGN_KEY).update(s).digest('hex')
return `${AUDIO_HOST}${s}&sign=${sign}`
}
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const songmid = String(songInfo.songmid ?? '')
const [channelId, programId] = songmid.split(':')
requestObj.promise = (async() => {
if (!channelId || !programId) {
throw new Error('蜻蜓 id 需为 <channelId>:<programId>(见 /album/qt 节目列表)')
}
return { type: type === '128k' ? '128k' : '64k', url: buildProgramUrl(channelId, programId) }
})()
return requestObj
}
export default { getMusicUrl, buildProgramUrl }
+21 -20
View File
@@ -2,40 +2,41 @@
// 接口: https://u.y.qq.com/cgi-bin/musicu.fcg // 接口: https://u.y.qq.com/cgi-bin/musicu.fcg
// 方法: vkey.GetVkeyServer.CgiGetVkey // 方法: vkey.GetVkeyServer.CgiGetVkey
// 取流方案: // 取流方案:
// 按请求音质组 filename 链一次请求(M500{media_mid}.mp3 / M800{media_mid}.mp3 / F000{media_mid}.flac), // 按请求音质组 filename 候选链一次请求(命中第一个有 purl 的档位即最高可用音质);
// 响应 midurlinfo 与 filename 一一对应,取第一个有 purl 的(即最高可用音质);
// 始终附带 C400{songmid}.m4a 保底(匿名可取的 128k)。 // 始终附带 C400{songmid}.m4a 保底(匿名可取的 128k)。
// 登录 cookie(uin + qm_keyst,见 /login/tx)可解锁 320k/无损。 // 档位前缀(社区资料 2026-10 核对):RS01 Hi-Res FLAC > Q000/AI00 大师/臻品母带 FLAC >
// F000 无损 FLAC > M800 320k MP3 > M500 128k MP3 > C400 128k AAC;
// VIP 档 filename 用 media_mid(非 songmid)。
// 登录 cookie(uin + qm_keyst,见 /login/tx)可解锁 320k/无损/Hi-Res。
// 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp } // 统一接口约定:apis('tx').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中的音质,可能低于请求值) // promise resolve → { type, url }(type 为实际命中的音质,可能低于请求值)
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { getTxAuth } from '../auth/store.ts' import { getTxAuth } from '../auth/store.ts'
// 音质 → [文件前缀, 扩展名](flac24bit 暂走 flac 通道,命中即返回) // 音质 → 候选档位链([prefix, ext, 命中后展示的 type],从高到低逐级回退)
const prefixMap = { const candidateMap: Record<string, Array<[string, string, string]>> = {
'128k': ['M500', 'mp3'], '128k': [['M500', 'mp3', '128k']],
'320k': ['M800', 'mp3'], '320k': [['M800', 'mp3', '320k'], ['M500', 'mp3', '128k']],
flac: ['F000', 'flac'], flac: [['F000', 'flac', 'flac'], ['M800', 'mp3', '320k'], ['M500', 'mp3', '128k']],
flac24bit: ['F000', 'flac'], flac24bit: [
['RS01', 'flac', 'flac24bit'],
['Q000', 'flac', 'flac24bit'],
['AI00', 'flac', 'flac24bit'],
['F000', 'flac', 'flac'],
['M800', 'mp3', '320k'],
['M500', 'mp3', '128k'],
],
} }
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality) // 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = Object.keys(prefixMap) export const supportQualitys = Object.keys(candidateMap)
// 降级链:高音质无版权/VIP 受限时逐级回退
const chainMap = {
'128k': ['128k'],
'320k': ['320k', '128k'],
flac: ['flac', '320k', '128k'],
flac24bit: ['flac', '320k', '128k'],
}
export const getMusicUrl = (songInfo, type) => { export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any } const requestObj = { cancelHttp() {}, promise: null as any }
const mediaMid = songInfo.strMediaMid || songInfo.songmid const mediaMid = songInfo.strMediaMid || songInfo.songmid
const entries: any[] = [] const entries: any[] = []
for (const t of chainMap[type] || chainMap['128k']) { for (const [prefix, ext, hitType] of candidateMap[type] || candidateMap['128k']) {
const [prefix, ext] = prefixMap[t] entries.push({ filename: `${prefix}${mediaMid}.${ext}`, type: hitType })
entries.push({ filename: `${prefix}${mediaMid}.${ext}`, type: t })
} }
entries.push({ filename: `C400${songInfo.songmid}.m4a`, type: '128k' }) entries.push({ filename: `C400${songInfo.songmid}.m4a`, type: '128k' })
+52
View File
@@ -0,0 +1,52 @@
// 喜马拉雅 URL 获取(PC 桌面端 baseInfo 通道)
// 链路:makeXmSign(hdaa 上报) → baseInfo device=win(带 PC 设备 Cookie) → playUrlList
// → decodeWinUrl(AES-ECB, 客户端 4.0.14 asar 密钥) → a.xmcdn.com 带签名直链
// 实测(2026-10-02)匿名免费曲目 HTTP 200 audio/mp4 可播;付费/VIP 曲 ret=1001(需登录,暂未接)
// 档位命名如 M4A_64/MP3_64/M4A_24/MP3_32/AAC_24(音乐内容另有 MP3_128/M4A_128);
// 喜马拉雅以有声内容为主,码率低于音乐平台,音质映射到 '64k'/'128k' 桶
// 统一接口约定:apis('xm').getMusicUrl(songInfo, type) → { promise, cancelHttp }
// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值)
import { getTrackInfo } from '../xm/musicInfo.ts'
import { decodeWinUrl } from '../xm/decoder.ts'
// 优先 M4A(同码率体积更小),128 档优先 MP3_128
const QUALITY_PREFERENCE: Record<string, string[]> = {
'128k': ['MP3_128', 'M4A_128', 'M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'],
'64k': ['M4A_64', 'MP3_64', 'M4A_24', 'MP3_32', 'AAC_24'],
}
export const supportQualitys = ['64k', '128k']
const bitrateOf = (type: string): number => Number(/(\d+)/.exec(type)?.[1] ?? 0) || 0
const bucketOf = (bitrate: number): string => (bitrate >= 128 ? '128k' : '64k')
export const getMusicUrl = (songInfo, type) => {
const requestObj = { cancelHttp() {}, promise: null as any }
const trackId = String(songInfo.songmid ?? '')
requestObj.promise = (async() => {
const trackInfo = await getTrackInfo(trackId)
const playList: any[] = trackInfo.playUrlList ?? []
if (!playList.length) throw new Error('无可用播放资源(付费/版权内容需登录)')
const preference = QUALITY_PREFERENCE[type] ?? QUALITY_PREFERENCE['128k']
let picked: { type: string, url: string } | null = null
for (const want of preference) {
const hit = playList.find(item => item?.type === want && item.url)
if (hit) {
picked = { type: hit.type, url: hit.url.startsWith('http') ? hit.url : decodeWinUrl(hit.url) }
break
}
}
if (!picked) {
// 兜底:未知档位命名时取码率最高的一条
const best = [...playList].filter(item => item?.url).sort((a, b) => bitrateOf(b.type) - bitrateOf(a.type))[0]
if (!best) throw new Error('无可用播放资源')
picked = { type: best.type, url: best.url.startsWith('http') ? best.url : decodeWinUrl(best.url) }
}
return { type: bucketOf(bitrateOf(picked.type)), url: picked.url }
})()
return requestObj
}
export default { getMusicUrl }
+77
View File
@@ -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 }
+106
View File
@@ -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
+50
View File
@@ -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 }
+74
View File
@@ -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 }
+22
View File
@@ -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 }
+165
View File
@@ -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,
}
+452
View File
@@ -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 '../common/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,
}
+245 -4
View File
@@ -1,14 +1,16 @@
// 酷狗登录 —— token 绑定 / 扫码 / 短信验证码(与 MakcRe/KuGouMusicApi 协议对齐) // 酷狗登录 —— token 绑定 / 扫码 / 短信验证码 / 账号密码(与 MakcRe/KuGouMusicApi 协议对齐)
// 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA; // 扫码走 login-user.kugou.com 的 web 签名通道;短信走 android 签名 + AES/RSA;
// 账密走 /v9/login_by_pwd,触发 20028 风控时返回验证码挑战(ssa-code 事件 +
// sid/edt 行为指纹,指纹由 kgSimulate.ts 服务端生成,无需浏览器 WASM);
// token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定 // token 也可从酷狗 App 抓包获取(login.token)后经 /login/kg 绑定
import crypto from 'node:crypto' import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts' import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from './store.ts' import { getCredentials, setCredentials } from './store.ts'
import { generateSimulate } from './kgSimulate.ts'
import type { KgCredentials } from './store.ts' import type { KgCredentials } from './store.ts'
const md5 = s => crypto.createHash('md5').update(s).digest('hex') const md5 = s => crypto.createHash('md5').update(s).digest('hex')
const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp' const SALT_SIGN = 'OIlwieks28dk2k092lksi2UIkp'
const APPID = 1005 const APPID = 1005
const CLIENTVER = 11430 const CLIENTVER = 11430
@@ -34,6 +36,82 @@ export const androidSign = (params, data = '') => {
return md5(SALT_SIGN + s + data + SALT_SIGN) return md5(SALT_SIGN + s + data + SALT_SIGN)
} }
/** RSA PKCS1 v1.5 加密(register_dev 专用;与登录的 RSA 裸填充不同) */
const rsaEncryptPkcs1Hex = (data: object) =>
crypto.publicEncrypt({ key: PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING }, Buffer.from(JSON.stringify(data), 'utf8')).toString('hex')
// ---- 设备 dfid 正式注册(userservice.kugou.com /risk/v2/r_register_dev)----
// 随机 dfid 匿名取流易撞 20028 验证;注册后的 dfid 是官方客户端形态,可显著降低风控。
// 协议(参考 KuGouMusicApi register_dev.js):设备画像 → AES-128-CBC(随机 6 位 key,
// key=md5(key)[0:16]、iv=md5(key)[16:32]) → base64 作 body;p = RSA-PKCS1({aes:key, uid, token})。
// 响应为原始二进制密文(非 JSON),按 base64 解码后同密钥 AES 解密得 {status, data:{dfid}}。
const playlistAesEncrypt = (data: object) => {
const key = randomString(6)
const keyHex = md5(key)
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
return {
key,
str: Buffer.concat([cipher.update(JSON.stringify(data), 'utf8'), cipher.final()]).toString('base64'),
}
}
const playlistAesDecrypt = (rawBase64: string, key: string) => {
const keyHex = md5(key)
const d = crypto.createDecipheriv('aes-128-cbc', Buffer.from(keyHex.slice(0, 16), 'utf8'), Buffer.from(keyHex.slice(16, 32), 'utf8'))
const out = Buffer.concat([d.update(Buffer.from(rawBase64, 'base64')), d.final()]).toString('utf8')
try { return JSON.parse(out) } catch { return out }
}
/** 注册正式 dfid(幂等:已注册直接返回;失败抛错由调用方决定是否容忍) */
export const registerDfid = async() => {
const dev = getDevice()
if ((dev as any).dfidRegistered) return dev
const cred = getCredentials('kg') as KgCredentials | null
const userid = Number(cred?.userid || 0)
const deviceMap = {
availableRamSize: 4983533568, availableRomSize: 48114719, availableSDSize: 48114717,
basebandVer: '', batteryLevel: 100, batteryStatus: 3,
brand: 'Redmi', buildSerial: 'unknown', device: 'marble',
imei: dev.guid, imsi: '', manufacturer: 'Xiaomi', uuid: dev.guid,
accelerometer: false, accelerometerValue: '', gravity: false, gravityValue: '',
gyroscope: false, gyroscopeValue: '', light: false, lightValue: '',
magnetic: false, magneticValue: '', orientation: false, orientationValue: '',
pressure: false, pressureValue: '', step_counter: false, step_counterValue: '',
temperature: false, temperatureValue: '',
}
const enc = playlistAesEncrypt(deviceMap)
const p = rsaEncryptPkcs1Hex({ aes: enc.key, uid: userid, token: cred?.token || '' })
const params: Record<string, any> = {
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
clienttime: Math.floor(Date.now() / 1000),
part: 1, platid: 1, p,
}
params.signature = androidSign(params, enc.str)
const qs = Object.keys(params).map(k => `${k}=${encodeURIComponent(params[k])}`).join('&')
const r = await httpFetch(`https://userservice.kugou.com/risk/v2/r_register_dev?${qs}`, {
method: 'post',
headers: {
dfid: dev.dfid, mid: dev.mid, clienttime: String(params.clienttime),
'User-Agent': 'Android15-1070-11083-46-0-DiscoveryDRADProtocol-wifi',
},
body: enc.str,
timeout: 15000,
}).promise as any
// 响应为二进制密文:raw base64 → AES 解密
const raw = r.raw ? Buffer.from(r.raw).toString('base64') : Buffer.from(JSON.stringify(r.body ?? '')).toString('base64')
const body = playlistAesDecrypt(raw, enc.key)
if (body?.status !== 1 || !body.data?.dfid) {
throw new Error(`dfid 注册失败(status ${body?.status ?? '未知'} ${body?.error ?? ''})`.trim())
}
const updated = { ...dev, dfid: String(body.data.dfid), dfidRegistered: true }
setCredentials('_kg_device', updated)
return updated
}
/** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */ /** 设备标识持久化(guid/mid/dfid 稳定复用,登录验证与取流共用同一指纹) */
export const getDevice = () => { export const getDevice = () => {
const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) } const dev = getCredentials('_kg_device') ?? { guid: crypto.randomUUID(), mid: '', dfid: randomString(24) }
@@ -44,13 +122,14 @@ export const getDevice = () => {
return dev return dev
} }
/** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域) */ /** 网关移动端签名请求(默认 gateway.kugou.com + x-router,可 host 直连登录专用域);withHeaders 时返回 { body, headers } */
const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {} }: { const gatewayRequest = async(url, { params: extraParams, data, router, host = 'https://gateway.kugou.com', headers: extraHeaders = {}, withHeaders = false }: {
params?: Record<string, any> params?: Record<string, any>
data?: any data?: any
router?: string router?: string
host?: string host?: string
headers?: Record<string, string> headers?: Record<string, string>
withHeaders?: boolean
}) => { }) => {
const dev = getDevice() const dev = getDevice()
const params = { const params = {
@@ -72,6 +151,7 @@ const gatewayRequest = async(url, { params: extraParams, data, router, host = 'h
body: data ?? undefined, body: data ?? undefined,
timeout: 15000, timeout: 15000,
}).promise }).promise
if (withHeaders) return { body: r.body ?? {}, headers: (r as any).headers ?? {} }
return r.body ?? {} return r.body ?? {}
} }
@@ -334,6 +414,167 @@ export const refreshLogin = async() => {
return { refreshed: true } return { refreshed: true }
} }
// ---- 账号密码登录(/v9/login_by_pwd)+ 20028 验证码挑战 ----
// 参考 KuGouMusicApi login.js / get_verify_info.js / verify_user_info.js 与 issue #206:
// 密码与验证码走 AES 随机密钥加密(params/pk);风控触发时响应 error_code=20028,
// 事件 id 在响应头 ssa-code(gz_tx_event_xxx);/verifyservice/v3/get_verify_info
// 按 data.url/v_type 区分验证类型(23 腾讯滑块、32 手机验证码、22 活体、34 人脸…);
// 验证结果经 /v4/verify_user_info 提交(verifycode 格式 KGCodeXX|{json} 或短信码),
// sid/edt 为行为指纹(kgSimulate 服务端生成),随后重试登录(短信码经 code 字段随密文上送)。
// login_by_pwd 的设备校验常量(与参考实现一致,疑似风控指纹固定值)
const PWD_T1 = '562a6f12a6e803453647d16a08f5f0c2ff7eee692cba2ab74cc4c8ab47fc467561a7c6b586ce7dc46a63613b246737c03a1dc8f8d162d8ce1d2c71893d19f1d4b797685a4c6d3d81341cbde65e488c4829a9b4d42ef2df470eb102979fa5adcdd9b4eecfea8b909ff7599abeb49867640f10c3c70fc444effca9d15db44a9a6c907731e2bb0f22cd9b3536380169995693e5f0e2424e3378097d3813186e3fe96bbe7023808a0981b4e2b6135a76faac'
const PWD_T2 = '31c4daf4cf480169ccea1cb7d4a209295865a9d2b788510301694db229b87807469ea0d41b4d4b9173c2151da7294aeebfc9738df154bbdf11a4e117bb5dff6a3af8ce5ce333e681c1f29a44038f27567d58992eb81283e080778ac77db1400fdf49b7cf7e26be2e5af4da7830cc3be4'
const PWD_T3 = 'MCwwLDAsMCwwLDAsMCwwLDA='
const PWD_CLIENTVER = 11440
// 验证码类型(get_verify_info 的 data.url 前缀 / v_type)
const VERIFY_VTYPES: Record<string, string> = {
16: 'QQ 绑定验证', 18: '微信绑定验证', 22: '活体认证', 23: '滑块/图形验证码', 32: '手机短信验证码', 34: '人脸核身',
}
const VERIFY_URL_TYPES: Record<string, string> = {
KGCodeGT: '极验', KGCodeTX: '腾讯', KGCodeKG: '酷狗旋转', KGCodeKG2: '酷狗滑块', KGCodeSM: '数美', KGCodeYD: '易盾',
}
export interface VerifyChallenge {
eventid: string
v_type: number
vTypeLabel: string
url?: string
provider?: string
txappid?: string
sessionid?: string
sid: string
edt: string
}
/** 查询验证事件详情(/verifyservice/v3/get_verify_info) */
export const getVerifyInfo = async(eventid: string) => {
const body = await gatewayRequest('/verifyservice/v3/get_verify_info', {
data: {
eventid, userid: Number(getCredentials('kg')?.userid ?? 0), platid: 2, rtype: 1,
wasm: 1, i: '', sid: '', edt: '',
},
})
if (body.status !== 1 && body.error_code !== 0) {
throw new Error(body.error ?? `查询验证信息失败(code ${body.error_code ?? '未知'})`)
}
return body.data ?? {}
}
/** 提交验证结果(/v4/verify_user_info,verifycode 为 KGCodeXX|{json} 或短信码) */
export const verifyUserInfo = async({ eventid, v_type, verifycode, sid, edt }: {
eventid: string
v_type: number
verifycode: string
sid?: string
edt?: string
}) => {
const dev = getDevice()
const userid = Number(getCredentials('kg')?.userid ?? 0)
const fp = sid && edt ? { sid, edt } : generateSimulate(dev.mid, userid, dev.dfid)
const dataMap: Record<string, any> = {
eventid, userid, platid: 2, v_type: Number(v_type) || 23, wasm: 1, i: '',
sid: fp.sid, edt: fp.edt,
}
if (dataMap.v_type === 32) {
// 手机短信验证码:明文 code + 密文 params 均携带
const enc = aesEncryptRandom({ code: verifycode })
Object.assign(dataMap, {
code: verifycode, verifycode,
pk: rsaEncryptHex({ key: enc.key }), params: enc.str,
})
} else {
// 滑块等图形验证码:KGCodeXX|{ticket,...} 明文上送
const enc = aesEncryptRandom({})
Object.assign(dataMap, {
verifycode,
pk: rsaEncryptHex({ key: enc.key }), params: enc.str,
})
}
const body = await gatewayRequest('/v4/verify_user_info', {
host: 'https://verifyservice.kugou.com',
params: { clientver: 11510 },
data: dataMap,
})
if (body.status !== 1) {
const err = new Error(body.error ?? `验证提交失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
return { verified: true }
}
/**
* 账号密码登录(/v9/login_by_pwd)
* 成功返回账号资料;命中 20028 风控返回 VerifyChallenge(先 /login/kg/verify 提交验证,
* 短信码场景再带 code 重试本接口)
*/
export const passwordLogin = async({ username, password, code }: { username: string, password: string, code?: string }) => {
// 账密登录风控最敏感,先确保 dfid 已正式注册(失败容忍,随机 dfid 继续尝试)
await registerDfid().catch(() => {})
const clienttimeMs = Date.now()
const enc = aesEncryptRandom({ pwd: String(password), code: String(code || ''), clienttime_ms: clienttimeMs })
const { body, headers } = await gatewayRequest('/v9/login_by_pwd', {
router: 'login.user.kugou.com',
params: { clientver: PWD_CLIENTVER },
data: {
plat: 1,
support_multi: 1,
clienttime_ms: clienttimeMs,
t1: PWD_T1,
t2: PWD_T2,
t3: PWD_T3,
dev: 'SDY-AN00',
username: String(username),
params: enc.str,
pk: rsaEncryptHex({ clienttime_ms: clienttimeMs, key: enc.key }).toUpperCase(),
},
headers: {
'User-Agent': `Android16-1070-${PWD_CLIENTVER}-130-0-LOGIN-wifi`,
'support-calm': '1',
},
withHeaders: true,
}) as { body: any, headers: Record<string, any> }
if (body.status === 1 && body.data?.secu_params) {
const decrypted = aesDecryptHex(body.data.secu_params, enc.key)
const token = typeof decrypted === 'object' ? decrypted?.token : decrypted
if (!token) throw new Error('登录响应解密失败')
return applyKgLogin({ token, userid: decrypted?.userid ?? body.data?.userid ?? 0 })
}
if (body.error_code === 20028) {
const eventid = String(headers?.['ssa-code'] ?? headers?.['SSA-CODE'] ?? body.ssaCode ?? '')
if (!eventid) {
throw new Error('触发风控验证但未返回事件 id(ssa-code),请改用扫码或短信登录')
}
const dev = getDevice()
const userid = Number(getCredentials('kg')?.userid ?? 0)
const { sid, edt } = generateSimulate(dev.mid, userid, dev.dfid)
const info = await getVerifyInfo(eventid).catch(() => ({}) as any)
const vType = Number(info.v_type ?? 23)
const url: string = String(info.url ?? '')
const provider = Object.keys(VERIFY_URL_TYPES).find(k => url.includes(k))
const challenge: VerifyChallenge = {
eventid,
v_type: vType,
vTypeLabel: VERIFY_VTYPES[vType] ?? `未知类型 ${vType}`,
url: url || undefined,
provider: provider ? VERIFY_URL_TYPES[provider] : undefined,
txappid: info.txappid,
sessionid: info.sessionid,
sid,
edt,
}
return challenge
}
const err = new Error(body.error ?? `登录失败(code ${body.error_code ?? '未知'})`)
;(err as any).status = 401
throw err
}
/** 当前 kg 登录资料(未登录返回 null) */ /** 当前 kg 登录资料(未登录返回 null) */
export const status = () => { export const status = () => {
const cred = getCredentials('kg') const cred = getCredentials('kg')
+116
View File
@@ -0,0 +1,116 @@
// 酷狗行为指纹模拟(sid/edt 生成)——移植 MakcRe/KuGouMusicApi util/generate_simulate.js
// 用途:20028 二次验证时替代浏览器 WASM 上报行为数据。
// 协议:明文 = mid=..;userid=..;dfid=..;webgl=..;webdriver=0;ts=..;data=<事件流>
// edt = AES-128-CBC(明文, key=md5(rand16)[0:16], iv='kugousecurity123') → Base64
// sid = RSA-OAEP-SHA256(key) → Base64(公钥取自酷狗 WASM 二进制)
// 事件流:贝塞尔曲线鼠标轨迹(type 3) + 滚动(type 5) + 窗口(type 6),条目间以 : 分隔,
// 每条事件后跟一条哨兵记录(时间差字段替换为接近 0xFFFFFFFF 的随机值)。
import crypto from 'node:crypto'
const md5 = (s: string) => crypto.createHash('md5').update(s).digest('hex')
const SIM_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAoW2+Ylo8ALePSQTP0xBF
lFmEOHvBD9tS+s7DBlfKEu3RzzvZTaX1JtYbX4+AVUqj6ARz8IM+CKByqGFvbHN/
W64XxNI+q7z36ajCL3VTJ2W5G9MCJitc6oGbire4NQfhaEq0nC+hxBWQvCbIFflA
2ItrLUbSU7z1bHA/a+jlQm4OWvY+IKnTryOJTPuT1yNOVjbJ8wBLKy2DgQr9pPqW
PmEQtGpR5IM9V8Kao6PaSdKYOWGbX3i2+RzIKhvZUxxtJwdVbqPlDPlW9h4/xIBc
56Lgvr4aIl8nFtwbj4UJVUTFuGrs0tY9H/tXvZ22dUCKuGxW/gW7ZF+gXz6vHtYa
rQIDAQAB
-----END PUBLIC KEY-----`
const SIM_IV = 'kugousecurity123'
const ri = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min
const randomString = (n: number) => {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
return Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
let sentinel = 0xffffffff - ri(0, 20)
const f3 = (t: number | string, i: number, x: number, y: number) => `3,${t},${i},${x},${y}`
const f5 = (t: number | string, i: number) => `5,${t},${i}`
const f6 = (t: number | string, i: number, x: number, y: number) => `6,${t},${i},${x},${y}`
// 三阶贝塞尔鼠标轨迹:随机控制点 + 起步抖动大、后程收敛
const bezierPath = (sx: number, sy: number, ex: number, ey: number, n: number) => {
const c1x = sx + (ex - sx) * 0.3 + ri(-80, 80)
const c1y = sy + (ey - sy) * 0.2 + ri(-60, 60)
const c2x = sx + (ex - sx) * 0.7 + ri(-60, 60)
const c2y = sy + (ey - sy) * 0.8 + ri(-40, 40)
const pts: Array<{ x: number, y: number }> = []
for (let i = 0; i <= n; i++) {
const t = i / n
const u = 1 - t
const x = u * u * u * sx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * ex
const y = u * u * u * sy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * ey
const jitter = Math.max(0.5, 3 - t * 2.5)
pts.push({ x: x + (Math.random() - 0.5) * jitter, y: y + (Math.random() - 0.5) * jitter })
}
return pts
}
const generateEdtData = ({ startX, startY, endX, endY, mousePoints }: {
startX: number, startY: number, endX: number, endY: number, mousePoints: number
}) => {
const entries: string[] = []
let ts = 0
let ei = 0
entries.push(f5(0, 0), f5(sentinel, 0), f5(0, 0), f5(sentinel, 0))
ts += ri(5, 20)
entries.push(f6(ts, ei, 750, 500), f6(sentinel, ei, 750, 500))
ei++
for (let i = 0; i < 3; i++) {
ts += ri(80, 600)
entries.push(f5(ts, ei), f5(sentinel, ei))
ei++
}
const path = bezierPath(startX, startY, endX, endY, mousePoints)
let si = 0
for (let i = 0; i < path.length; i++) {
const { x, y } = path[i]
ts += ri(8, 50)
const rx = Math.round(x)
const ry = Math.round(y)
entries.push(f3(ts, si, rx, ry), f3(sentinel, si, rx, ry))
if (i > 0 && i % 12 === 0) {
ts += ri(20, 60)
entries.push(f5(ts, ei), f5(sentinel, ei))
ei++
}
si = (si + 1) % 2
}
ts += ri(5, 30)
entries.push(f3(ts, 1, Math.round(endX + ri(-5, 5)), Math.round(endY + ri(-5, 5))), f3(sentinel, 1, Math.round(endX), Math.round(endY)))
return entries.join(':')
}
/** 生成模拟行为指纹(sid/edt),验证接口 /v4/verify_user_info 原样上送 */
export const generateSimulate = (mid: string | number, userid: string | number, dfid: string, webglHash?: string) => {
sentinel = 0xffffffff - ri(0, 20)
const key = md5(randomString(16)).slice(0, 16)
const points = ri(30, 60)
const startX = ri(200, 600)
const startY = ri(200, 500)
const endX = ri(500, 700)
const endY = ri(80, 150)
const webgl = webglHash || md5(randomString(32))
const plaintext = `mid=${mid || 0};userid=${userid || 0};dfid=${dfid || 0};webgl=${webgl};webdriver=0;ts=${Date.now()};data=${generateEdtData({ startX, startY, endX, endY, mousePoints: points })}`
const cipher = crypto.createCipheriv('aes-128-cbc', Buffer.from(key, 'utf8'), Buffer.from(SIM_IV, 'utf8'))
const edt = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]).toString('base64')
const sid = crypto.publicEncrypt(
{ key: SIM_PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
Buffer.from(key, 'utf8'),
).toString('base64')
return { edt, sid }
}
export default { generateSimulate }
+56 -6
View File
@@ -1,12 +1,17 @@
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库) // 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} } // 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_passport_device: {...} }
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build // 凭据是明文敏感数据:文件以 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 { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url' 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 { export interface WyCredentials {
cookie: string cookie: string
@@ -45,14 +50,50 @@ export interface TxCredentials {
updatedAt?: string updatedAt?: string
} }
export interface XmCredentials {
// www.ximalaya.com 登录 Cookie(须含 1&_token,从已登录浏览器请求头复制)
// baseInfo 付费内容授权依赖它;无法主动续期,失效后需重新复制绑定
cookie: string
updatedAt?: string
}
export interface MeCredentials {
// App 登录 Cookie(须含 token=,从猫耳 App 抓包或已登录 m.missevan.com 浏览器复制)
// App API 付费声音授权依赖它;无法主动续期
cookie: string
updatedAt?: string
}
export interface LrCredentials {
// m.lrts.me 登录会话 Cookie(/login/lr 账号密码登录后由服务端保存)
// 取流接口必须携带(免费章节也要求登录态)
cookie: string
phone?: string
updatedAt?: string
}
let cache: Record<string, any> = {} let cache: Record<string, any> = {}
let loaded = false let loaded = false
const load = (): Record<string, any> => { const load = (): Record<string, any> => {
if (loaded) return cache if (loaded) return cache
if (!existsSync(DATA_FILE)) {
cache = {}
loaded = true
return cache
}
try { try {
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {} 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 { } catch {
console.error(`[auth/store] ${DATA_FILE} 解析失败且备份未成功,本次以空凭据启动: ${(err as Error).message}`)
}
cache = {} cache = {}
} }
loaded = true loaded = true
@@ -63,7 +104,16 @@ const save = (data: Record<string, any>): void => {
cache = data cache = data
loaded = true loaded = true
mkdirSync(dirname(DATA_FILE), { recursive: 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 export const getCredentials = (platform: string): any => load()[platform] ?? null
+22
View File
@@ -113,6 +113,28 @@ export const cellphoneLogin = async({ phone, password, captcha, countrycode = '8
return profile return profile
} }
/**
* 手动绑定 cookie(从已登录的 music.163.com 浏览器请求头复制,须含 MUSIC_U)。
* 绑定前用 account/get 实测校验,失效直接报 401,与 kg 绑定 token 同款语义。
*/
export const loginByCookie = async(cookieInput: string) => {
const cookie = String(cookieInput || '').trim()
const musicU = /MUSIC_U=([^;\s]+)/.exec(cookie)?.[1]
if (!musicU) throw Object.assign(new Error('cookie 需包含 MUSIC_U 字段'), { status: 422 })
const { body } = await requestWeapi('/api/w/nuser/account/get', {}, cookie)
if (!body?.account?.id) {
throw Object.assign(new Error('cookie 无效或已过期(MUSIC_U 校验未通过)'), { status: 401 })
}
const profile = {
userid: body.account.id,
nickname: body.profile?.nickname ?? null,
avatar: body.profile?.avatarUrl ?? null,
vipType: body.profile?.vipType ?? null,
}
setCredentials('wy', { cookie, musicU, ...profile })
return profile
}
/** 当前账号资料(未登录返回 null) */ /** 当前账号资料(未登录返回 null) */
export const status = async() => { export const status = async() => {
if (!getWyCookie()) return null if (!getWyCookie()) return null
+180
View File
@@ -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: 'bd' 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: 'bd' 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: 'bd' 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: 'bd' 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 }
+86
View File
@@ -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('bd') 读取(可选)。
import bdApi from '../apis/bd.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 bd = {
musicSearch,
getMusicUrl(songInfo, type) {
return bdApi.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 bd
+34
View File
@@ -0,0 +1,34 @@
// 千千音乐歌词
// 链路:/v1/song/info → data[0].lyric 为歌词文本 URL(static-qianqian.taihe.com/*.txt)→ 直接下载文本。
// 返回 { lyric, tlyric: '' }(千千只有原文歌词,无翻译)。
import { httpFetch } from '../common/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 }
+26
View File
@@ -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 }
+35
View File
@@ -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: 'bd' 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: 'bd' as const,
}
}
export default { search }
+35
View File
@@ -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/bd.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: 'bd' as const }
}
export const parseAlbum = async(link: string) => parseAlbumLink(link)
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+158
View File
@@ -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: 'bd' as const,
}
}
/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => {
const word = String(keyword ?? '').trim()
const empty = { source: 'bd' 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: 'bd' 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: 'bd' 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: 'bd' 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: 'bd' 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,
}
+222
View File
@@ -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('bd') 读取(可选,未登录留空)。
import crypto from 'node:crypto'
import { httpFetch } from '../common/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('bd') 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: 'bd' 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,
}
+39
View File
@@ -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
+163
View File
@@ -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 }
+12
View File
@@ -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 }
+57
View File
@@ -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 }
+72
View File
@@ -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 会立即填充音频地址(失败忽略);urls 已按 CDN 分级排序,取最优候选
if (cid) {
try {
const audio = await getAudioUrl(song.bvid, cid)
if (audio?.urls?.[0]) {
song.url = audio.urls[0]
song.type = audio.type
}
} catch {
// 忽略:元数据仍可返回
}
}
return song
}
/** 歌单/合集链接解析(上游 bilibili/playlist.go 的 `ParsePlaylist`) */
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
export default { parseSong, parsePlaylist }
+17
View File
@@ -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 }
+257
View File
@@ -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,
}
+307
View File
@@ -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* 的对应实现) ----
/** B 站 API 统一校验:HTTP 200 且业务码 code=0,否则抛带上下文的错误 */
const assertApiOk = (what: string, statusCode: number, body: any): void => {
if (statusCode !== 200 || !body || body.code !== 0) {
throw new Error(`B站${what}获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`)
}
}
/** 视频详情(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}`)
assertApiOk('视频信息', statusCode, body)
if (!body.data) throw new Error('B站视频信息获取失败(data 为空)')
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)}`)
assertApiOk('分P列表', statusCode, body)
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 { statusCode, body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
assertApiOk('合集信息', statusCode, body)
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 { statusCode, body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`)
assertApiOk('合集列表', statusCode, body)
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
}
+1 -1
View File
@@ -97,5 +97,5 @@ export const decodeName = (str = '') => {
.replace(/&#39;/g, "'") .replace(/&#39;/g, "'")
.replace(/&apos;/g, "'") .replace(/&apos;/g, "'")
.replace(/&nbsp;/g, ' ') .replace(/&nbsp;/g, ' ')
.replace(/&#(\d+);/g, (match, num) => String.fromCharCode(parseInt(num))) .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num)))
} }
+57 -14
View File
@@ -2,10 +2,32 @@ import kw from './kw/index.ts'
import kg from './kg/index.ts' import kg from './kg/index.ts'
import tx from './tx/index.ts' import tx from './tx/index.ts'
import wy from './wy/index.ts' import wy from './wy/index.ts'
import qs from './qs/index.ts'
import xm from './xm/index.ts'
import me from './me/index.ts'
import qt from './qt/index.ts'
import lr from './lr/index.ts'
import mg from './mg/index.ts'
import bd from './bd/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 kwQualitys } from './apis/kw.ts'
import { supportQualitys as kgQualitys } from './apis/kg.ts' import { supportQualitys as kgQualitys } from './apis/kg.ts'
import { supportQualitys as txQualitys } from './apis/tx.ts' import { supportQualitys as txQualitys } from './apis/tx.ts'
import { supportQualitys as wyQualitys } from './apis/wy.ts' import { supportQualitys as wyQualitys } from './apis/wy.ts'
import { supportQualitys as qsQualitys } from './apis/qs.ts'
import { supportQualitys as xmQualitys } from './apis/xm.ts'
import { supportQualitys as meQualitys } from './apis/me.ts'
import { supportQualitys as qtQualitys } from './apis/qt.ts'
import { supportQualitys as lrQualitys } from './apis/lr.ts'
import { supportQualitys as mgQualitys } from './apis/mg.ts'
import { supportQualitys as bdQualitys } from './apis/bd.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 type { PlatformModule, SearchResult, SongInfo } from './types.ts'
import { import {
sizeFormate, sizeFormate,
@@ -20,16 +42,29 @@ import {
export { sizeFormate, formatPlayTime, dateFormat, dateFormat2, formatPlayCount, decodeName } export { sizeFormate, formatPlayTime, dateFormat, dateFormat2, formatPlayCount, decodeName }
const SINGERS_SEPARATOR = /、|&|;|;|\/|,|,|\||\]|\[|!|!/g const SINGERS_SEPARATOR = /、|&|;|;|\/|,|,|\||\]|\[|!|!/g
const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule }> = [ // noAggregate:有声/播客类平台不参与聚合搜索与跨平台匹配(xm/me),仅支持 source 直查
const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModule; noAggregate?: boolean }> = [
{ id: 'kw', name: '酷我音乐', module: kw }, { id: 'kw', name: '酷我音乐', module: kw },
{ id: 'kg', name: '酷狗音乐', module: kg }, { id: 'kg', name: '酷狗音乐', module: kg },
{ id: 'tx', name: 'QQ音乐', module: tx }, { id: 'tx', name: 'QQ音乐', module: tx },
{ id: 'wy', name: '网易音乐', module: wy }, { id: 'wy', name: '网易音乐', module: wy },
{ id: 'qs', name: '汽水音乐', module: qs },
{ id: 'xm', name: '喜马拉雅', module: xm, noAggregate: true },
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
{ id: 'lr', name: '懒人听书', module: lr, noAggregate: true },
{ id: 'mg', name: '咪咕音乐', module: mg },
{ id: 'bd', name: '千千音乐', module: bd },
{ 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])) const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
export const sources = SUPPORTED_SOURCES.map(({ id, name }) => ({ id, name })) export const sources = SUPPORTED_SOURCES.map(({ id, name, noAggregate }) => ({ id, name, ...(noAggregate ? { noAggregate: true } : {}) }))
export const init = (): Promise<unknown[]> => export const init = (): Promise<unknown[]> =>
Promise.all(SUPPORTED_SOURCES.map((s) => s.module.init?.()).filter(Boolean)) Promise.all(SUPPORTED_SOURCES.map((s) => s.module.init?.()).filter(Boolean))
@@ -41,6 +76,17 @@ export const supportQuality: Record<string, string[]> = {
kg: kgQualitys, kg: kgQualitys,
tx: txQualitys, tx: txQualitys,
wy: wyQualitys, wy: wyQualitys,
qs: qsQualitys,
xm: xmQualitys,
me: meQualitys,
qt: qtQualitys,
lr: lrQualitys,
mg: mgQualitys,
bd: bdQualitys,
joox: jooxQualitys,
bili: biliQualitys,
'5sing': fivesingQualitys,
apple: appleQualitys,
} }
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string) const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
@@ -82,19 +128,19 @@ interface MatchCandidate extends SongInfo {
* @param params.name 歌名 * @param params.name 歌名
* @param params.singer 歌手 * @param params.singer 歌手
* @param params.excludeSource 指定平台时,聚合结果会跳过该平台 * @param params.excludeSource 指定平台时,聚合结果会跳过该平台
* @param params.only 只搜该平台(与 excludeSource 互斥,优先生效)
* @param params.limit 单平台结果条数 * @param params.limit 单平台结果条数
*/ */
export const searchMusic = async ( export const searchMusic = async (
{ name, singer, source: excludeSource, limit = 25 }: { name: string; singer?: string; source?: string; limit?: number }, { name, singer, source: excludeSource, only, limit = 25 }: { name: string; singer?: string; source?: string; only?: string; limit?: number },
): Promise<Array<SearchResult | null>> => { ): Promise<Array<SearchResult | null>> => {
const musicName = trimStr(name) const musicName = trimStr(name)
const tasks: Array<Promise<SearchResult | null>> = [] const tasks: Array<Promise<SearchResult | null>> = []
for (const source of SUPPORTED_SOURCES) { for (const source of SUPPORTED_SOURCES) {
if ( if (!source.module.musicSearch) continue
source.id === excludeSource || // 有声平台(noAggregate)仅在显式 only 指定时参与搜索
!source.module.musicSearch if (source.noAggregate && source.id !== only) continue
) if (only ? source.id !== only : source.id === excludeSource) continue
continue
tasks.push( tasks.push(
source.module.musicSearch source.module.musicSearch
.search(`${musicName} ${singer || ''}`.trim(), 1, limit) .search(`${musicName} ${singer || ''}`.trim(), 1, limit)
@@ -116,11 +162,12 @@ const pickMatching = <T>(items: T[], predicate: (item: T) => boolean): { matched
/** /**
* 跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序) * 跨平台精确匹配歌曲(按歌手、歌名、专辑、时长综合排序)
* @param params.only 只在该平台内匹配(跨平台兜底取流用)
*/ */
export const findMusic = async ( export const findMusic = async (
{ name, singer, albumName, interval, source: s }: { name: string; singer?: string; albumName?: string; interval?: string; source?: string }, { name, singer, albumName, interval, source: s, only }: { name: string; singer?: string; albumName?: string; interval?: string; source?: string; only?: string },
): Promise<MatchCandidate[]> => { ): Promise<MatchCandidate[]> => {
const lists = await searchMusic({ name, singer, source: s, limit: 25 }) const lists = await searchMusic({ name, singer, source: s, only, limit: 25 })
const fMusicName = filterStr(name).toLowerCase() const fMusicName = filterStr(name).toLowerCase()
const fSinger = filterStr(sortSingle(singer)).toLowerCase() const fSinger = filterStr(sortSingle(singer)).toLowerCase()
@@ -129,10 +176,6 @@ export const findMusic = async (
const isEqualsInterval = (intv: number) => const isEqualsInterval = (intv: number) =>
Math.abs((fInterval || intv) - (intv || fInterval)) < 5 Math.abs((fInterval || intv) - (intv || fInterval)) < 5
const isIncludesName = (n: string) => fMusicName.includes(n) || n.includes(fMusicName)
const isIncludesSinger = (sng: string) =>
fSinger ? fSinger.includes(sng) || sng.includes(fSinger) : true
const isEqualsAlbum = (alb: string) => (fAlbumName ? fAlbumName === alb : true)
let candidates: MatchCandidate[] = [] let candidates: MatchCandidate[] = []
for (const source of lists) { for (const source of lists) {
+169
View File
@@ -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 '../common/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 }
+62
View File
@@ -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
+84
View File
@@ -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 实体(&amp; 等)→ { lyric, tlyric: '' }
// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric }
import { httpFetch } from '../common/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 }
+83
View File
@@ -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 '../common/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 兜底;并还原 &amp; 实体 */
export const pickTrackUrl = (detail: any): string => {
const clean = (value: any) => String(value ?? '').replace(/&amp;/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 }
+42
View File
@@ -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 '../common/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 }
+28
View File
@@ -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 { parseAlbum } from './album.ts'
import { 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 }
+316
View File
@@ -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 '../common/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,
}
+73
View File
@@ -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
}
+181
View File
@@ -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 '../common/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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#0*39;/g, "'")
.replace(/&nbsp;/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 页面重定向次数过多')
}
+63
View File
@@ -1,5 +1,8 @@
import { getMusicInfosByList } from './musicInfo.ts' import { getMusicInfosByList } from './musicInfo.ts'
import { createHttpFetch } from './util.ts' import { createHttpFetch } from './util.ts'
import { httpFetch } from '../common/request.ts'
const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240')
export default { 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('无效的酷狗专辑链接')
},
} }
+1 -5
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeName, dateFormat2 } from '../index.ts' import { decodeName, dateFormat2 } from '../index.ts'
import { signatureParams } from './util.ts' import { signatureParams } from './util.ts'
// import { getMusicInfoRaw } from './musicInfo.ts' // import { getMusicInfoRaw } from './musicInfo.ts'
@@ -22,13 +22,11 @@ export default {
}, },
}) })
const { body, statusCode } = await _requestObj.promise const { body, statusCode } = await _requestObj.promise
// console.log(body)
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取评论失败') if (statusCode != 200 || body.err_code !== 0) throw new Error('获取评论失败')
const total = body.count ?? 0 const total = body.count ?? 0
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
}, },
async getHotComment({ hash }, page = 1, limit = 20) { async getHotComment({ hash }, page = 1, limit = 20) {
// console.log(songmid)
if (this._requestObj2) this._requestObj2.cancelHttp() if (this._requestObj2) this._requestObj2.cancelHttp()
let timestamp = Date.now() let timestamp = Date.now()
const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0` const params = `dfid=0&mid=16249512204336365674023395779019&clienttime=${timestamp}&uuid=0&extdata=${hash}&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a&schash=${hash}&clientver=11409&p=${page}&clienttoken=&pagesize=${limit}&ver=10&kugouid=0`
@@ -39,7 +37,6 @@ export default {
}, },
}) })
const { body, statusCode } = await _requestObj2.promise const { body, statusCode } = await _requestObj2.promise
// console.log(body)
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取热门评论失败') if (statusCode != 200 || body.err_code !== 0) throw new Error('获取热门评论失败')
const total = body.count ?? 0 const total = body.count ?? 0
return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 } return { source: 'kg', comments: this.filterComment(body.list || []), total, page, limit, maxPage: Math.ceil(total / limit) || 1 }
@@ -57,7 +54,6 @@ export default {
}, },
}) })
const { body, statusCode } = await _requestObj2.promise const { body, statusCode } = await _requestObj2.promise
// console.log(body)
if (statusCode != 200 || body.err_code !== 0) throw new Error('获取回复评论失败') if (statusCode != 200 || body.err_code !== 0) throw new Error('获取回复评论失败')
return { source: 'kg', comments: this.filterComment(body.list || []) } return { source: 'kg', comments: this.filterComment(body.list || []) }
}, },
+1 -2
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeName } from '../index.ts' import { decodeName } from '../index.ts'
import type { HttpRequestObject } from '../types.ts' import type { HttpRequestObject } from '../types.ts'
@@ -21,7 +21,6 @@ export default {
}) })
const { body, statusCode } = await _requestObj.promise const { body, statusCode } = await _requestObj.promise
if (statusCode != 200 || body.errcode !== 0) throw new Error('获取热搜词失败') if (statusCode != 200 || body.errcode !== 0) throw new Error('获取热搜词失败')
// console.log(body, statusCode)
return { source: 'kg', list: this.filterList(body.data.list) } return { source: 'kg', list: this.filterList(body.data.list) }
}, },
filterList(rawList) { filterList(rawList) {
+9
View File
@@ -8,8 +8,15 @@ import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts' import singer from './singer.ts'
import album from './album.ts' import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import { registerDfid } from '../auth/kg.ts'
const kg = { const kg = {
// 启动时注册正式 dfid(幂等;随机 dfid 匿名取流易撞 20028 验证,注册后显著降低)
async init() {
await registerDfid().catch((err: any) => console.log(`[kg/init] dfid 注册跳过: ${err.message}`))
},
leaderboard, leaderboard,
songList, songList,
musicSearch, musicSearch,
@@ -17,6 +24,8 @@ const kg = {
comment, comment,
singer, singer,
album, album,
playlist,
parse,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return kgApi.getMusicUrl(songInfo, type) return kgApi.getMusicUrl(songInfo, type)
}, },
+3 -1
View File
@@ -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 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) => { 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) const buf_str = Buffer.from(str, 'base64').subarray(4)
for (let i = 0, len = buf_str.length; i < len; i++) { for (let i = 0, len = buf_str.length; i < len; i++) {
buf_str[i] = buf_str[i] ^ enc_key[i % 16] buf_str[i] = buf_str[i] ^ enc_key[i % 16]
+2 -23
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts' import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
import { formatSingerName } from '../utils.ts' import { formatSingerName } from '../utils.ts'
import type { HttpRequestObject, SongType, QualityInfo } from '../types.ts' import type { HttpRequestObject, SongType, QualityInfo } from '../types.ts'
@@ -91,7 +91,6 @@ export default {
return arr.join('、') return arr.join('、')
}, },
filterData(rawList) { filterData(rawList) {
// console.log(rawList)
return rawList.map(item => { return rawList.map(item => {
const types: Array<SongType & { hash?: string }> = [] const types: Array<SongType & { hash?: string }> = []
const _types: Record<string, QualityInfo & { hash?: string }> = {} const _types: Record<string, QualityInfo & { hash?: string }> = {}
@@ -147,7 +146,6 @@ export default {
}, },
filterBoardsData(rawList) { filterBoardsData(rawList) {
// console.log(rawList)
let list: Array<{ id: string; name: string; bangid: string }> = [] let list: Array<{ id: string; name: string; bangid: string }> = []
for (const board of rawList) { for (const board of rawList) {
if (board.isvol != 1) continue if (board.isvol != 1) continue
@@ -159,24 +157,7 @@ export default {
} }
return list return list
}, },
async getBoards(retryNum = 0) { async getBoards() {
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
// let response
// try {
// response = await this.getBoardsData()
// } catch (error) {
// return this.getBoards(retryNum)
// }
// // console.log(response.body)
// if (response.statusCode !== 200 || response.body.errcode !== 0) return this.getBoards(retryNum)
// const list = this.filterBoardsData(response.body.data.info)
// console.log(list)
// // console.log(JSON.stringify(list))
// this.list = list
// return {
// list,
// source: 'kg',
// }
this.list = boardList this.list = boardList
return { return {
list: boardList, list: boardList,
@@ -189,11 +170,9 @@ export default {
if (body.errcode != 0) return this.getList(bangid, page, retryNum) if (body.errcode != 0) return this.getList(bangid, page, retryNum)
// console.log(body)
let total = body.data.total let total = body.data.total
let limit = 100 let limit = 100
let listData = this.filterData(body.data.info) let listData = this.filterData(body.data.info)
// console.log(listData)
return { return {
total, total,
list: listData, list: listData,
+4 -25
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeKrc } from './krcDecode.ts' import { decodeKrc } from './krcDecode.ts'
export default { export default {
@@ -13,28 +13,6 @@ export default {
} }
return parseInt(intv as unknown as string) return parseInt(intv as unknown as string)
}, },
// getLyric(songInfo, tryNum = 0) {
// let requestObj = httpFetch(`http://m.kugou.com/app/i/krc.php?cmd=100&keyword=${encodeURIComponent(songInfo.name)}&hash=${songInfo.hash}&timelength=${songInfo._interval || this.getIntv(songInfo.interval)}&d=0.38664927426725626`, {
// headers: {
// 'KG-RC': 1,
// 'KG-THash': 'expand_search_manager.cpp:852736169:451',
// 'User-Agent': 'KuGou2012-9020-ExpandSearchManager',
// },
// })
// requestObj.promise = requestObj.promise.then(({ body, statusCode }) => {
// if (statusCode !== 200) {
// if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
// let tryRequestObj = this.getLyric(songInfo, ++tryNum)
// requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
// return tryRequestObj.promise
// }
// return {
// lyric: body,
// tlyric: '',
// }
// })
// return requestObj
// },
searchLyric(name, hash, time, tryNum = 0) { searchLyric(name, hash, time, tryNum = 0) {
let requestObj = httpFetch(`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`, { let requestObj = httpFetch(`http://lyrics.kugou.com/search?ver=1&man=yes&client=pc&keyword=${encodeURIComponent(name)}&hash=${hash}&timelength=${time}&lrctxt=1`, {
headers: { headers: {
@@ -69,7 +47,8 @@ export default {
requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => { requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => {
if (statusCode !== 200) { if (statusCode !== 200) {
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败')) 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) requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
return tryRequestObj.promise return tryRequestObj.promise
} }
@@ -91,7 +70,7 @@ export default {
return requestObj return requestObj
}, },
getLyric(songInfo, tryNum = 0) { getLyric(songInfo) {
let requestObj = this.searchLyric(songInfo.name, songInfo.hash, songInfo._interval || this.getIntv(songInfo.interval)) let requestObj = this.searchLyric(songInfo.name, songInfo.hash, songInfo._interval || this.getIntv(songInfo.interval))
requestObj.promise = requestObj.promise.then(result => { requestObj.promise = requestObj.promise.then(result => {
-1
View File
@@ -39,7 +39,6 @@ const createGetMusicInfosTask = (hashs: Array<{ hash: string }>): Array<Promise<
} }
export const filterMusicInfoList = (rawList: any[]): SongInfo[] => { export const filterMusicInfoList = (rawList: any[]): SongInfo[] => {
// console.log(rawList)
let ids = new Set() let ids = new Set()
let list: SongInfo[] = [] let list: SongInfo[] = []
rawList.forEach(item => { rawList.forEach(item => {
+1 -1
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeName, formatPlayTime, sizeFormate } from '../index.ts' import { decodeName, formatPlayTime, sizeFormate } from '../index.ts'
import { formatSingerName } from '../utils.ts' import { formatSingerName } from '../utils.ts'
import type { SongType, QualityInfo } from '../types.ts' import type { SongType, QualityInfo } from '../types.ts'
+111
View File
@@ -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 '../common/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 }
+1 -1
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
export default { export default {
getPic(songInfo) { getPic(songInfo) {
+237
View File
@@ -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 '../common/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,
}
+2 -145
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts' import { decodeName, formatPlayTime, sizeFormate, dateFormat, formatPlayCount } from '../index.ts'
import { toMD5 } from '../utils.ts' import { toMD5 } from '../utils.ts'
import { signatureParams } from './util.ts' import { signatureParams } from './util.ts'
@@ -66,51 +66,6 @@ export default {
// https://www.kugou.com/yy/special/single/1067062.html // https://www.kugou.com/yy/special/single/1067062.html
listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/, listDetailLink: /^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)/,
}, },
// async getGlobalSpecialId(specialId) {
// return httpFetch(`http://mobilecdnbj.kugou.com/api/v5/special/info?specialid=${specialId}`, {
// headers: {
// 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70',
// },
// }).promise.then(({ body }) => {
// // console.log(body)
// if (!body.data.global_specialid) Promise.reject(new Error('Failed to get global collection id.'))
// return body.data.global_specialid
// })
// },
// async getListInfoBySpecialId(special_id, retry = 0) {
// if (++retry > 2) throw new Error('failed')
// return httpFetch(`https://m.kugou.com/plist/list/${special_id}/?json=true`, {
// headers: {
// 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; HLK-AL00) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Mobile Safari/537.36 EdgA/104.0.1293.70',
// },
// follow_max: 2,
// }).promise.then(({ body }) => {
// // console.log(body)
// if (!body.info.list) return this.getListInfoBySpecialId(special_id, retry)
// let listinfo = body.info.list
// return {
// listInfo: {
// name: listinfo.specialname,
// image: listinfo.imgurl.replace('{size}', '150'),
// intro: listinfo.intro,
// author: listinfo.nickname,
// playcount: listinfo.playcount,
// total: listinfo.songcount,
// },
// globalSpecialId: listinfo.global_specialid,
// }
// })
// },
// async getSongListDetailByGlobalSpecialId(id, page, limit = 100, retry = 0) {
// if (++retry > 2) throw new Error('failed')
// console.log(id)
// const params = `specialid=0&need_sort=1&module=CloudMusic&clientver=11409&pagesize=${limit}&global_collection_id=${id}&userid=0&page=${page}&type=1&area_code=1&appid=1005`
// return httpFetch(`http://pubsongscdn.tx.kugou.com/v2/get_other_list_file?${params}&signature=${signatureParams(params)}`).promise.then(({ body }) => {
// // console.log(body)
// if (body.data?.info == null) return this.getSongListDetailByGlobalSpecialId(id, page, limit, retry)
// return body.data.info
// })
// },
parseHtmlDesc(html) { parseHtmlDesc(html) {
const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">' const prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
let index = html.indexOf(prefix) let index = html.indexOf(prefix)
@@ -148,30 +103,9 @@ export default {
name, name,
img: pic, img: pic,
desc, desc,
// author: body.result.info.userinfo.username,
// play_count: formatPlayCount(body.result.listen_num),
}, },
} }
// const globalSpecialId = await this.getGlobalSpecialId(id)
// const limit = 100
// const listData = await this.getSongListDetailByGlobalSpecialId(globalSpecialId, page, limit)
// if (!Array.isArray(listData))
// return this.getUserListDetail2(globalSpecialId)
// return {
// list: this.filterDatav9(listData),
// page,
// limit,
// total: listInfo.total,
// source: 'kg',
// info: {
// name: listInfo.name,
// img: listInfo.image,
// desc: listInfo.intro,
// author: listInfo.author,
// play_count: formatPlayCount(listInfo.playcount),
// },
// }
}, },
getInfoUrl(tagId?) { getInfoUrl(tagId?) {
return tagId return tagId
@@ -276,10 +210,9 @@ export default {
try { try {
result = await httpFetch(url, options).promise result = await httpFetch(url, options).promise
} catch (err) { } catch (err) {
console.log(err) console.warn(`[kg] 歌单请求失败将重试(${retryNum + 1}/3) ${url}: ${(err as Error).message}`)
return this.createHttp(url, options, ++retryNum) return this.createHttp(url, options, ++retryNum)
} }
// console.log(result.statusCode, result.body)
if (result.statusCode !== 200 || if (result.statusCode !== 200 ||
( (
(result.body.error_code !== undefined (result.body.error_code !== undefined
@@ -349,7 +282,6 @@ export default {
}, },
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id }, body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: id },
}) })
// console.log(songInfo)
// type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列 // type 1单曲,2歌单,3电台,4酷狗码,5别人的播放队列
let songList let songList
let info = songInfo.info let info = songInfo.info
@@ -372,7 +304,6 @@ export default {
}, },
body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: info.id, type: 3, userid: info.userid, collect_type: 0, page: 1, pagesize: info.count } }, body: { appid: 1001, clientver: 9020, mid: '21511157a05844bd085308bc76ef3343', clienttime: 640612895, key: '36164c4015e704673c588ee202b9ecb8', data: { id: info.id, type: 3, userid: info.userid, collect_type: 0, page: 1, pagesize: info.count } },
}) })
// console.log(songList)
} }
let list = await this.getMusicInfos(songList || songInfo.list) let list = await this.getMusicInfos(songList || songInfo.list)
return { return {
@@ -402,7 +333,6 @@ export default {
else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain)) else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain))
} }
let list = await this.getMusicInfos(songInfo.list) let list = await this.getMusicInfos(songInfo.list)
// console.log(info, songInfo)
return { return {
list, list,
page: 1, page: 1,
@@ -468,7 +398,6 @@ export default {
} }
let result = await Promise.all(tasks).then(([...datas]) => datas.flat()) let result = await Promise.all(tasks).then(([...datas]) => datas.flat())
result = await this.getMusicInfos(result) result = await this.getMusicInfos(result)
// console.log(result)
return { return {
list: result, list: result,
page, page,
@@ -519,7 +448,6 @@ export default {
}) })
const songInfo = await this.createGetListDetail2Task(id, info.songcount) const songInfo = await this.createGetListDetail2Task(id, info.songcount)
let list = await this.getMusicInfos(songInfo) let list = await this.getMusicInfos(songInfo)
// console.log(info, songInfo, list)
return { return {
list, list,
page: 1, page: 1,
@@ -561,7 +489,6 @@ export default {
if (result) result = JSON.parse(result[1]) if (result) result = JSON.parse(result[1])
this.cache.set(chain, result) this.cache.set(chain, result)
result = await this.getMusicInfos(result) result = await this.getMusicInfos(result)
// console.log(info, songInfo)
return result return result
}, },
@@ -618,9 +545,7 @@ export default {
}, },
}) })
// console.log(info)
let result = await this.getMusicInfos(info.info) let result = await this.getMusicInfos(info.info)
// console.log(info, songInfo)
return result return result
}, },
@@ -655,10 +580,8 @@ export default {
}) })
const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise
const loc = location as string | undefined const loc = location as string | undefined
// console.log(body, location)
if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum) if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum)
if (loc) { if (loc) {
// console.log(location)
if (loc.includes('global_collection_id')) return this.getUserListDetail2(loc.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) if (loc.includes('global_collection_id')) return this.getUserListDetail2(loc.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'))
if (loc.includes('gcid_')) { if (loc.includes('gcid_')) {
let gcid = link.match(/gcid_\w+/)?.[0] let gcid = link.match(/gcid_\w+/)?.[0]
@@ -679,7 +602,6 @@ export default {
return this.getUserListDetail(link, page, ++retryNum) return this.getUserListDetail(link, page, ++retryNum)
} else return this.getUserListDetail3(loc.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } else return this.getUserListDetail3(loc.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page)
} }
// console.log('location', location)
return this.getUserListDetail(loc, page, ++retryNum) return this.getUserListDetail(loc, page, ++retryNum)
} }
if (typeof body == 'string') { if (typeof body == 'string') {
@@ -713,7 +635,6 @@ export default {
return this.getListDetailBySpecialId(id, page) return this.getListDetailBySpecialId(id, page)
}, },
filterData(rawList) { filterData(rawList) {
// console.log(rawList)
return rawList.map(item => { return rawList.map(item => {
const types: Array<{ type: string; size: string; hash?: string }> = [] const types: Array<{ type: string; size: string; hash?: string }> = []
const _types: Record<string, QualityInfo & { hash?: string }> = {} const _types: Record<string, QualityInfo & { hash?: string }> = {}
@@ -766,72 +687,9 @@ export default {
} }
}) })
}, },
// getSinger(singers) {
// let arr = []
// singers?.forEach(singer => {
// arr.push(singer.name)
// })
// return arr.join('、')
// },
// v9 API
// filterDatav9(rawList) {
// console.log(rawList)
// return rawList.map(item => {
// const types = []
// const _types = {}
// item.relate_goods.forEach(qualityObj => {
// if (qualityObj.level === 2) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '128k', size, hash: qualityObj.hash })
// _types['128k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 4) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: '320k', size, hash: qualityObj.hash })
// _types['320k'] = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 5) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac', size, hash: qualityObj.hash })
// _types.flac = {
// size,
// hash: qualityObj.hash,
// }
// } else if (qualityObj.level === 6) {
// let size = sizeFormate(qualityObj.size)
// types.push({ type: 'flac24bit', size, hash: qualityObj.hash })
// _types.flac24bit = {
// size,
// hash: qualityObj.hash,
// }
// }
// })
// const nameInfo = item.name.split(' - ')
// return {
// singer: this.getSinger(item.singerinfo),
// name: decodeName((nameInfo[1] ?? nameInfo[0]).trim()),
// albumName: decodeName(item.albuminfo.name),
// albumId: item.albuminfo.id,
// songmid: item.audio_id,
// source: 'kg',
// interval: formatPlayTime(item.timelen / 1000),
// img: null,
// lrc: null,
// hash: item.hash,
// types,
// _types,
// typeUrl: {},
// }
// })
// },
// hash list filter // hash list filter
filterData2(rawList) { filterData2(rawList) {
// console.log(rawList)
let ids = new Set() let ids = new Set()
let list: SongInfo[] = [] let list: SongInfo[] = []
rawList.forEach(item => { rawList.forEach(item => {
@@ -959,7 +817,6 @@ export default {
return httpFetch(`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`) return httpFetch(`http://msearchretry.kugou.com/api/v3/search/special?keyword=${encodeURIComponent(text)}&page=${page}&pagesize=${limit}&showtype=10&filter=0&version=7910&sver=2`)
.promise.then(({ body }) => { .promise.then(({ body }) => {
if (body.errcode != 0) throw new Error('filed') if (body.errcode != 0) throw new Error('filed')
// console.log(body.data.info)
return { return {
list: body.data.info.map(item => { list: body.data.info.map(item => {
return { return {
+2 -4
View File
@@ -1,9 +1,8 @@
import { toMD5 } from '../utils.ts' import { toMD5 } from '../utils.ts'
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import type { HttpOptions } from '../types.ts' import type { HttpOptions } from '../types.ts'
// s.content[0].lyricContent.forEach(([str]) => { // s.content[0].lyricContent.forEach(([str]) => {
// console.log(str)
// }) // })
/** /**
@@ -32,10 +31,9 @@ export const createHttpFetch = async(url: string, options?: HttpOptions, retryNu
try { try {
result = await httpFetch(url, options).promise result = await httpFetch(url, options).promise
} catch (err) { } catch (err) {
console.log(err) console.warn(`[kg] 请求失败将重试(${retryNum + 1}/3) ${url}: ${(err as Error).message}`)
return createHttpFetch(url, options, ++retryNum) return createHttpFetch(url, options, ++retryNum)
} }
// console.log(result.statusCode, result.body)
if (result.statusCode !== 200 || if (result.statusCode !== 200 ||
( (
result.body.error_code ?? result.body.error_code ??
+135 -32
View File
@@ -1,16 +1,23 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeName } from '../index.ts' import { decodeName } from '../index.ts'
import { formatSinger, objStr2JSON } from './util.ts' import { formatSinger, objStr2JSON } from './util.ts'
import type { Quality } from '../types.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 // let requestObj_list
export default { export default {
limit_list: 36, limit_list: 36,
limit_song: 1000, limit_song: 1000,
filterListDetail(rawList, albumName, albumId) { filterListDetail(rawList, albumName, albumId) {
// console.log(rawList) return rawList.map(item => {
// console.log(rawList.length, rawList2.length)
return rawList.map((item, inedx) => {
let formats = item.formats.split('|') let formats = item.formats.split('|')
let types: Array<{ type: Quality; size: string | null }> = [] let types: Array<{ type: Quality; size: string | null }> = []
let _types: Partial<Record<Quality, { size: string | null }>> = {} let _types: Partial<Record<Quality, { size: string | null }>> = {}
@@ -83,7 +90,6 @@ export default {
return requestObj_listDetail.promise.then(({ statusCode, body }) => { return requestObj_listDetail.promise.then(({ statusCode, body }) => {
if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum) if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
body = objStr2JSON(body) body = objStr2JSON(body)
// console.log(body)
if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum) if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum)
body.name = decodeName(body.name) body.name = decodeName(body.name)
return { return {
@@ -102,31 +108,128 @@ export default {
} }
}) })
}, },
// getAlbumListDetail(id, page, retryNum = 0) { /**
// if (retryNum > 2) return Promise.reject(new Error('try max num')) * 专辑详情(统一形状;服务端 1000 条/页,limit 更小时本地按 page/limit 开窗)
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => { * 上游对应:kuwo.go fetchAlbumDetail(legacy API 部分)
// return resp.promise.then(({ statusCode, body }) => { */
// console.log(body) async getAlbumDetail(id: string, page = 1, limit = 1000) {
// return Promise.reject(new Error('failed')) const albumID = String(id ?? '').trim()
// // if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum) if (!albumID) throw new Error('酷我专辑 id 为空')
// // const data = body.data const pageNo = Math.max(1, Number(page) || 1)
// // console.log(data) const pageSize = Math.max(1, Number(limit) || this.limit_song)
// // if (!data.musicList) return this.getAlbumListDetail(id, page, ++retryNum) if (pageSize >= this.limit_song) return this.getAlbumListDetail(albumID, pageNo)
// // return {
// // list: this.filterListDetail(data.musiclist), const start = (pageNo - 1) * pageSize
// // page, let serverPage = Math.floor(start / this.limit_song) + 1
// // limit: this.limit_song, let skip = start % this.limit_song
// // total: data.total, const list: any[] = []
// // source: 'kw', let info: any = null
// // info: { let total = 0
// // name: data.album, while (list.length < pageSize && serverPage <= 50) {
// // img: data.pic, const res = await this.getAlbumListDetail(albumID, serverPage)
// // desc: data.albuminfo, if (!res) break
// // author: data.artist, if (!info) info = res.info ?? null
// // play_count: this.formatPlayCount(data.playCnt), 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('无效的酷我专辑链接')
},
} }
+1 -3
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { dateFormat2 } from '../index.ts' import { dateFormat2 } from '../index.ts'
import type { HttpRequestObject } from '../types.ts' import type { HttpRequestObject } from '../types.ts'
@@ -15,7 +15,6 @@ export default {
}) })
const { body, statusCode } = await _requestObj.promise const { body, statusCode } = await _requestObj.promise
if (statusCode != 200 || body.code != '200') throw new Error('获取评论失败') if (statusCode != 200 || body.code != '200') throw new Error('获取评论失败')
// console.log(body)
const total = body.comments_counts const total = body.comments_counts
return { return {
@@ -37,7 +36,6 @@ export default {
}) })
const { body, statusCode } = await _requestObj2.promise const { body, statusCode } = await _requestObj2.promise
if (statusCode != 200 || body.code != '200') throw new Error('获取热门评论失败') if (statusCode != 200 || body.code != '200') throw new Error('获取热门评论失败')
// console.log(body)
const total = body.hot_comments_counts const total = body.hot_comments_counts
return { return {
+1 -2
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import type { HttpRequestObject } from '../types.ts' import type { HttpRequestObject } from '../types.ts'
export default { export default {
@@ -14,7 +14,6 @@ export default {
}) })
const { body, statusCode } = await _requestObj.promise const { body, statusCode } = await _requestObj.promise
if (statusCode != 200 || body.status !== 'ok') throw new Error('获取热搜词失败') if (statusCode != 200 || body.status !== 'ok') throw new Error('获取热搜词失败')
// console.log(body, statusCode)
return { source: 'kw', list: this.filterList(body.tagvalue) } return { source: 'kw', list: this.filterList(body.tagvalue) }
}, },
filterList(rawList) { filterList(rawList) {
+5 -21
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import musicSearch from './musicSearch.ts' import musicSearch from './musicSearch.ts'
import { formatSinger } from './util.ts' import { formatSinger } from './util.ts'
import leaderboard from './leaderboard.ts' import leaderboard from './leaderboard.ts'
@@ -9,6 +9,8 @@ import songList from './songList.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import album from './album.ts' import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import type { HttpRequestObject } from '../types.ts' import type { HttpRequestObject } from '../types.ts'
const kw = { const kw = {
@@ -17,22 +19,8 @@ const kw = {
_musicPicRequestObj: null as HttpRequestObject | null, _musicPicRequestObj: null as HttpRequestObject | null,
_musicPicPromiseCancelFn: null, _musicPicPromiseCancelFn: null,
album, album,
// context: null, playlist,
parse,
// init(context) {
// if (this.isInited) return
// this.isInited = true
// this.context = context
// // this.musicSearch.search('我又想你了').then(res => {
// // console.log(res)
// // })
// // this.getMusicUrl('62355680', '320k').then(url => {
// // console.log(url)
// // })
// },
musicSearch, musicSearch,
leaderboard, leaderboard,
@@ -45,7 +33,6 @@ const kw = {
}, },
handleMusicInfo(songInfo) { handleMusicInfo(songInfo) {
return this.getMusicInfo(songInfo).then(info => { return this.getMusicInfo(songInfo).then(info => {
// console.log(JSON.stringify(info))
songInfo.name = info.name songInfo.name = info.name
songInfo.singer = formatSinger(info.artist) songInfo.singer = formatSinger(info.artist)
songInfo.img = info.pic songInfo.img = info.pic
@@ -95,9 +82,6 @@ const kw = {
return `http://www.kuwo.cn/play_detail/${songInfo.songmid}` return `http://www.kuwo.cn/play_detail/${songInfo.songmid}`
}, },
// init() {
// return getToken()
// },
} }
export default kw export default kw
+2 -24
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { formatPlayTime, decodeName } from '../index.ts' import { formatPlayTime, decodeName } from '../index.ts'
import { formatSinger, wbdCrypto } from './util.ts' import { formatSinger, wbdCrypto } from './util.ts'
import type { HttpRequestObject, Quality, QualityInfo, SongType } from '../types.ts' import type { HttpRequestObject, Quality, QualityInfo, SongType } from '../types.ts'
@@ -167,7 +167,6 @@ export default {
}, },
filterBoardsData(rawList) { filterBoardsData(rawList) {
// console.log(rawList)
let list: any[] = [] let list: any[] = []
for (const board of rawList) { for (const board of rawList) {
if (board.source != '1') continue if (board.source != '1') continue
@@ -179,24 +178,7 @@ export default {
} }
return list return list
}, },
async getBoards(retryNum = 0) { async getBoards() {
// if (++retryNum > 3) return Promise.reject(new Error('try max num'))
// let response
// try {
// response = await this.getBoardsData()
// } catch (error) {
// return this.getBoards(retryNum)
// }
// console.log(response.body)
// if (response.statusCode !== 200 || !response.body.child) return this.getBoards(retryNum)
// const list = this.filterBoardsData(response.body.child)
// // console.log(list)
// console.log(JSON.stringify(list))
// this.list = list
// return {
// list,
// source: 'kw',
// }
this.list = boardList this.list = boardList
return { return {
list: boardList, list: boardList,
@@ -213,7 +195,6 @@ export default {
return request.then(({ statusCode, body }) => { return request.then(({ statusCode, body }) => {
const rawData = wbdCrypto.decodeData(body) const rawData = wbdCrypto.decodeData(body)
// console.log(rawData)
const data = rawData.data const data = rawData.data
if (statusCode !== 200 || rawData.code != 200 || !data.musiclist) return this.getList(id, page, retryNum) if (statusCode !== 200 || rawData.code != 200 || !data.musiclist) return this.getList(id, page, retryNum)
@@ -230,7 +211,4 @@ export default {
}) })
}, },
// getDetailPageUrl(id) {
// return `http://www.kuwo.cn/rankList/${id}`
// },
} }
+1 -151
View File
@@ -1,71 +1,7 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { decodeLyric, lrcTools } from './util.ts' import { decodeLyric, lrcTools } from './util.ts'
import { decodeName } from '../index.ts' import { decodeName } from '../index.ts'
/*
export default {
formatTime(time) {
let m = parseInt(time / 60)
let s = (time % 60).toFixed(2)
return (m < 10 ? '0' + m : m) + ':' + (s < 10 ? '0' + s : s)
},
sortLrcArr(arr) {
const lrcSet = new Set()
let lrc = []
let lrcT = []
for (const item of arr) {
if (lrcSet.has(item.time)) {
const tItem = lrc.pop()
tItem.time = lrc[lrc.length - 1].time
lrcT.push(tItem)
lrc.push(item)
} else {
lrc.push(item)
lrcSet.add(item.time)
}
}
if (lrcT.length && lrc.length > lrcT.length) {
const tItem = lrc.pop()
tItem.time = lrc[lrc.length - 1].time
lrcT.push(tItem)
}
return {
lrc,
lrcT,
}
},
transformLrc(songinfo, lrclist) {
return `[ti:${songinfo.songName}]\n[ar:${songinfo.artist}]\n[al:${songinfo.album}]\n[by:]\n[offset:0]\n${lrclist ? lrclist.map(l => `[${this.formatTime(l.time)}]${l.lineLyric}\n`).join('') : '暂无歌词'}`
},
getLyric(songId) {
const requestObj = httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${songId}`)
requestObj.promise = requestObj.promise.then(({ body }) => {
// console.log(body)
if (!body.data?.lrclist?.length) return Promise.reject(new Error('Get lyric failed'))
let lrcInfo
try {
lrcInfo = this.sortLrcArr(body.data.lrclist)
} catch {
return Promise.reject(new Error('Get lyric failed'))
}
// console.log(body.data.lrclist)
// console.log(lrcInfo.lrc, lrcInfo.lrcT)
// console.log({
// lyric: decodeName(this.transformLrc(body.data.songinfo, lrc)),
// tlyric: decodeName(this.transformLrc(body.data.songinfo, lrcT)),
// })
return {
lyric: decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrc)),
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(body.data.songinfo, lrcInfo.lrcT)) : '',
}
})
return requestObj
},
}
*/
const buf_key = Buffer.from('yeelion') const buf_key = Buffer.from('yeelion')
const buf_key_len = buf_key.length const buf_key_len = buf_key.length
@@ -87,65 +23,11 @@ const buildParams = (id: string, isGetLyricx: boolean): string => {
return Buffer.from(output).toString('base64') return Buffer.from(output).toString('base64')
} }
// console.log(buildParams('207527604', false))
// console.log(buildParams('207527604', true))
const timeExp = /^\[([\d:.]*)\]{1}/g const timeExp = /^\[([\d:.]*)\]{1}/g
const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/ const existTimeExp = /\[\d{1,2}:.*\d{1,4}\]/
const lyricxTag = /^<-?\d+,-?\d+>/ const lyricxTag = /^<-?\d+,-?\d+>/
export default { export default {
/* sortLrcArr(arr) {
const lrcSet = new Set()
let lrc = []
let lrcT = []
let markIndex = []
for (const item of arr) {
if (lrcSet.has(item.time)) {
if (lrc.length < 2) continue
const index = lrc.findIndex(l => l.time == item.time)
markIndex.push(index)
if (index == lrc.length - 1) {
lrcT.push({ ...lrc[index], time: item.time })
lrc.push(item)
} else {
lrcT.push({ ...lrc[index], time: lrc[index + 1].time })
if (item.text) {
// const lastIndex = lrc.length - 1
// markIndex.push(lastIndex)
// lrcT.push({ ...lrc[lastIndex], time: lrc[lastIndex - 1].time })
lrc.push(item)
}
}
} else {
lrc.push(item)
lrcSet.add(item.time)
}
}
// console.log(markIndex)
markIndex = Array.from(new Set(markIndex))
for (let index = markIndex.length - 1; index >= 0; index--) {
lrc.splice(markIndex[index], 1)
}
// if (lrcT.length) {
// if (lrc.length * 0.4 < lrcT.length) { // 翻译数量需大于歌词数量的0.4倍,否则认为没有翻译
// const tItem = lrc.pop()
// tItem.time = lrc[lrc.length - 1].time
// lrcT.push(tItem)
// } else {
// lrc = arr
// lrcT = []
// }
// }
console.log(lrc, lrcT)
return {
lrc,
lrcT,
}
}, */
sortLrcArr(arr) { sortLrcArr(arr) {
const lrcSet = new Set() const lrcSet = new Set()
let lrc: any[] = [] let lrc: any[] = []
@@ -211,48 +93,17 @@ export default {
tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '', tlyric: lrcInfo.lrcT.length ? decodeName(this.transformLrc(tags, lrcInfo.lrcT)) : '',
} }
}, },
// getLyric2(musicInfo, isGetLyricx = true) {
// const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
// requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
// if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
// return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => {
// let lrcInfo
// console.log(Buffer.from(base64Data, 'base64').toString())
// try {
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
// } catch {
// return Promise.reject(new Error('Get lyric failed'))
// }
// if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
// lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
// // console.log(lrcInfo.lyric)
// // console.log(lrcInfo.tlyric)
// // console.log(lrcInfo.yrc)
// // console.log(JSON.stringify(lrcInfo))
// })
// })
// return requestObj
// },
getLyric(musicInfo, isGetLyricx = true) { getLyric(musicInfo, isGetLyricx = true) {
// this.getLyric2(musicInfo)
const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`) const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`)
requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => { requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => {
if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body))) if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body)))
return decodeLyric({ lrcBase64: raw!.toString('base64'), isGetLyricx }).then(base64Data => { return decodeLyric({ lrcBase64: raw!.toString('base64'), isGetLyricx }).then(base64Data => {
// let lrcInfo
// try {
// lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
// } catch {
// return Promise.reject(new Error('Get lyric failed'))
// }
let lrcInfo let lrcInfo
// console.log(Buffer.from(base64Data, 'base64').toString())
try { try {
lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString()) lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString())
} catch (err) { } catch (err) {
return Promise.reject(new Error('Get lyric failed')) return Promise.reject(new Error('Get lyric failed'))
} }
// console.log(lrcInfo)
if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '') if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '')
try { try {
lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric) lrcInfo.yrc = lrcTools.parse(lrcInfo.lyric)
@@ -261,7 +112,6 @@ export default {
} }
lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '') lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '')
if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed')) if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed'))
// console.log(lrcInfo)
return lrcInfo return lrcInfo
}) })
}) })
+1 -11
View File
@@ -1,6 +1,6 @@
// import '../../polyfill/array.find.js' // import '../../polyfill/array.find.js'
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { formatPlayTime, decodeName } from '../index.ts' import { formatPlayTime, decodeName } from '../index.ts'
// import { debug } from '../../utils/env.ts' // import { debug } from '../../utils/env.ts'
import { formatSinger } from './util.ts' import { formatSinger } from './util.ts'
@@ -14,21 +14,13 @@ export default {
total: 0, total: 0,
page: 0, page: 0,
allPage: 1, allPage: 1,
// cancelFn: null,
musicSearch(str: string, page: number, limit: number) { musicSearch(str: string, page: number, limit: number) {
const musicSearchRequestObj = httpFetch(`http://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(str)}&pn=${page - 1}&rn=${limit}&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1`) const musicSearchRequestObj = httpFetch(`http://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(str)}&pn=${page - 1}&rn=${limit}&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1`)
return musicSearchRequestObj.promise return musicSearchRequestObj.promise
}, },
// getImg(songId) {
// return httpGet(`http://player.kuwo.cn/webmusic/sj/dtflagdate?flag=6&rid=MUSIC_${songId}`)
// },
// getLrc(songId) {
// return httpGet(`http://mobile.kuwo.cn/mpage/html5/songinfoandlrc?mid=${songId}&flag=0`)
// },
handleResult(rawData: any) { handleResult(rawData: any) {
const result: SongInfo[] = [] const result: SongInfo[] = []
if (!rawData) return result if (!rawData) return result
// console.log(rawData)
for (let i = 0; i < rawData.length; i++) { for (let i = 0; i < rawData.length; i++) {
const info = rawData[i] const info = rawData[i]
let songId = info.MUSICRID.replace('MUSIC_', '') let songId = info.MUSICRID.replace('MUSIC_', '')
@@ -97,7 +89,6 @@ export default {
typeUrl: {}, typeUrl: {},
}) })
} }
// console.log(result)
return result return result
}, },
search(str: string, page = 1, limit?: number, retryNum = 0) { search(str: string, page = 1, limit?: number, retryNum = 0) {
@@ -105,7 +96,6 @@ export default {
if (limit == null) limit = this.limit if (limit == null) limit = this.limit
// http://newlyric.kuwo.cn/newlyric.lrc?62355680 // http://newlyric.kuwo.cn/newlyric.lrc?62355680
return this.musicSearch(str, page, limit).then(({ body: result }) => { return this.musicSearch(str, page, limit).then(({ body: result }) => {
// console.log(result)
if (!result || (result.TOTAL !== '0' && result.SHOW === '0')) return this.search(str, page, limit, ++retryNum) if (!result || (result.TOTAL !== '0' && result.SHOW === '0')) return this.search(str, page, limit, ++retryNum)
let list = this.handleResult(result.abslist) let list = this.handleResult(result.abslist)
+107
View File
@@ -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 '../common/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 }
+1 -1
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import type { SongInfo } from '../types.ts' import type { SongInfo } from '../types.ts'
export default { export default {
+217
View File
@@ -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,
}
+5 -11
View File
@@ -1,4 +1,4 @@
import { httpFetch } from '../request.ts' import { httpFetch } from '../common/request.ts'
import { formatPlayTime, decodeName } from '../index.ts' import { formatPlayTime, decodeName } from '../index.ts'
import { formatSinger, objStr2JSON } from './util.ts' import { formatSinger, objStr2JSON } from './util.ts'
import album from './album.ts' import album from './album.ts'
@@ -147,7 +147,6 @@ export default {
})) }))
}, },
filterList2(rawData) { filterList2(rawData) {
// console.log(rawData)
const list: any[] = [] const list: any[] = []
rawData.forEach(item => { rawData.forEach(item => {
if (!item.label) return if (!item.label) return
@@ -193,16 +192,16 @@ export default {
if (tryNum > 2) return Promise.reject(new Error('try max num')) 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`) 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 }) => { return requestObj.promise.then(({ statusCode, body }) => {
if (statusCode != 200 || !body.child) return this.getListDetail(id, ++tryNum) // 本方法须返回 detailId(调用方据此再查曲目),失败时应重试自身;
// console.log(body) // 原先误调 getListDetail(返回歌单详情对象、且把 tryNum 当 page)导致返回类型错位
if (statusCode != 200 || !body.child) return this.getListDetailDigest5Info(id, ++tryNum)
return body.child.length ? body.child[0].sourceid : null return body.child.length ? body.child[0].sourceid : null
}) })
}, },
getListDetailDigest5Music(id, page, tryNum = 0) { getListDetailDigest5Music(id, page, tryNum = 0) {
if (tryNum > 2) return Promise.reject(new Error('try max num')) 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 }) => { return requestObj.promise.then(({ body }) => {
// console.log(body)
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum) if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
return { return {
list: this.filterListDetail(body.musiclist), list: this.filterListDetail(body.musiclist),
@@ -312,7 +311,6 @@ export default {
if (infoData.code != 200) return null if (infoData.code != 200) return null
// console.log(infoData)
return { return {
name: infoData.data.userInfo.nickname + '喜欢的音乐', name: infoData.data.userInfo.nickname + '喜欢的音乐',
img: infoData.data.userInfo.headImg, img: infoData.data.userInfo.headImg,
@@ -365,13 +363,11 @@ export default {
author: '', author: '',
play_count: '', play_count: '',
} }
// console.log(listData)
return listData return listData
}, },
// 获取歌曲列表内的音乐 // 获取歌曲列表内的音乐
getListDetail(id, page, retryNum = 0) { getListDetail(id, page, retryNum = 0) {
// console.log(id)
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=5&ownerId=000 // https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=5&ownerId=000
// https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=4&ownerId= // https://h5app.kuwo.cn/m/bodian/collection.html?uid=000&playlistId=000&source=4&ownerId=
if (/\/bodian\//.test(id)) return this.getListDetailMusicListByBD(id, page) if (/\/bodian\//.test(id)) return this.getListDetailMusicListByBD(id, page)
@@ -391,7 +387,6 @@ export default {
return this.getListDetailDigest8(id, page, retryNum) return this.getListDetailDigest8(id, page, retryNum)
}, },
filterListDetail(rawData) { filterListDetail(rawData) {
// console.log(rawData)
return rawData.map(item => { return rawData.map(item => {
let infoArr = item.N_MINFO.split(';') let infoArr = item.N_MINFO.split(';')
let types: SongType[] = [] let types: SongType[] = []
@@ -462,7 +457,6 @@ export default {
return httpFetch(`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`) return httpFetch(`http://search.kuwo.cn/r.s?all=${encodeURIComponent(text)}&pn=${page - 1}&rn=${limit}&rformat=json&encoding=utf8&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0`)
.promise.then(({ body }) => { .promise.then(({ body }) => {
body = objStr2JSON(body) body = objStr2JSON(body)
// console.log(body)
return { return {
list: body.abslist.map(item => { list: body.abslist.map(item => {
return { return {
-67
View File
@@ -1,33 +1,7 @@
// import { httpGet, httpFetch } from '../request.ts'
import { handleKwDecodeLyric } from './lyricDecode.ts' import { handleKwDecodeLyric } from './lyricDecode.ts'
import { createCipheriv, createDecipheriv } from 'crypto' import { createCipheriv, createDecipheriv } from 'crypto'
import { toMD5 } from '../utils.ts' import { toMD5 } from '../utils.ts'
// const kw_token = {
// token: null,
// isGetingToken: false,
// }
// const translationMap = {
// "{'": '{"',
// "'}\n": '"}',
// "'}": '"}',
// "':'": '":"',
// "','": '","',
// "':{'": '":{"',
// "':['": '":["',
// "'}],'": '"}],"',
// "':[{'": '":[{"',
// "'},'": '"},"',
// "'},{'": '"},{"',
// "':[],'": '":[],"',
// "':{},'": '":{},"',
// "'}]}": '"}]}',
// }
// export const objStr2JSON = str => {
// return JSON.parse(str.replace(/(^{'|'}\n$|'}$|':'|','|':\[{'|'}\],'|':{'|'},'|'},{'|':\['|':\[\],'|':{},'|'}]})/g, s => translationMap[s]))
// }
export const objStr2JSON = (str: string): any => { export const objStr2JSON = (str: string): any => {
return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"')) return JSON.parse(str.replace(/('(?=(,\s*')))|('(?=:))|((?<=([:,]\s*))')|((?<={)')|('(?=}))/g, '"'))
@@ -44,46 +18,9 @@ export const matchToken = (headers: Record<string, any>): string | null => {
} }
} }
// const wait = time => new Promise(resolve => setTimeout(() => resolve(), time))
// export const getToken = (retryNum = 0) => new Promise((resolve, reject) => {
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
// if (kw_token.isGetingToken) return wait(1000).then(() => getToken(retryNum).then(token => resolve(token)))
// if (kw_token.token) return resolve(kw_token.token)
// kw_token.isGetingToken = true
// httpGet('http://www.kuwo.cn/', (err, resp) => {
// kw_token.isGetingToken = false
// if (err) return getToken(++retryNum)
// if (resp.statusCode != 200) return reject(new Error('获取失败'))
// const token = kw_token.token = matchToken(resp.headers)
// resolve(token)
// })
// })
export const decodeLyric = handleKwDecodeLyric export const decodeLyric = handleKwDecodeLyric
// export const tokenRequest = async(url, options = {}) => {
// let token = kw_token.token
// if (!token) token = await getToken()
// if (!options.headers) {
// options.headers = {
// Referer: 'http://www.kuwo.cn/',
// csrf: token,
// cookie: 'kw_token=' + token,
// }
// }
// const requestObj = httpFetch(url, options)
// requestObj.promise = requestObj.promise.then(resp => {
// // console.log(resp)
// if (resp.statusCode == 200) {
// kw_token.token = matchToken(resp.headers)
// }
// return resp
// })
// return requestObj
// }
interface LrcWordInfo { interface LrcWordInfo {
startTime: number startTime: number
@@ -117,7 +54,6 @@ export const lrcTools = {
} }
prevWord.newTimeStr = `<${prevWord.startTime},${prevWord.endTime - prevWord.startTime}>` prevWord.newTimeStr = `<${prevWord.startTime},${prevWord.endTime - prevWord.startTime}>`
// console.log(prevWord)
} }
} }
return { return {
@@ -137,7 +73,6 @@ export const lrcTools = {
} }
const wordTimes = words.match(this.rxps.wordTimeAll) const wordTimes = words.match(this.rxps.wordTimeAll)
if (!wordTimes) return if (!wordTimes) return
// console.log(wordTimes)
let preTimeInfo let preTimeInfo
for (const timeStr of wordTimes) { for (const timeStr of wordTimes) {
const result = this.rxps.wordTime.exec(timeStr) const result = this.rxps.wordTime.exec(timeStr)
@@ -167,7 +102,6 @@ export const lrcTools = {
} }
}, },
parse(lrc: string): string { parse(lrc: string): string {
// console.log(lrc)
const lines = lrc.split(/\r\n|\r|\n/) const lines = lrc.split(/\r\n|\r|\n/)
const tools = Object.create(this) const tools = Object.create(this)
tools.isOK = true tools.isOK = true
@@ -183,7 +117,6 @@ export const lrcTools = {
if (!tools.lines.length) return '' if (!tools.lines.length) return ''
let lrcs = tools.lines.join('\n') let lrcs = tools.lines.join('\n')
if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}` if (tools.tags.length) lrcs = `${tools.tags.join('\n')}\n${lrcs}`
// console.log(lrcs)
return lrcs return lrcs
}, },
} }
+35
View File
@@ -0,0 +1,35 @@
// 懒人听书 App API(dapis.mting.info)—— 搜索通道(游客 token 匿名可用)
// 签名(移植 loyalflower/lrts_download download.go):
// sc = md5(urlPath + "?" + <按 key 排序的 query(不含sc) 拼接> + secret),secret = "iJ0DgxmdC83#I&j@iwg"
// 头:UA Android13/yyting/... / Referer yytingting.com / ClientVersion 6.3.4.0
// 端点:/yyting/search/searchBatch.action(q=2150)
// 注意:章节/取流 App 端点(q=1930/2224)游客 token 已失效(482 请登录),取流走 web 通道(见 apis/lr.ts)
import crypto from 'node:crypto'
import { httpFetch } from '../common/request.ts'
const DOMAIN = 'http://dapis.mting.info'
const SECRET = 'iJ0DgxmdC83#I&j@iwg'
const GUEST_TOKEN = 'OqzlvCxt2i_P1SZKF6GjFg**_lK0uCQpm5tN-P6XdFZYawCDKSgeC4anU'
const GUEST_IMEI = 'MDI6MDA6MDA6MDA6MDA6DDA='
const APP_HEADERS = {
'User-Agent': 'Android13/yyting/vivo/V2133A/ch_uc_beta/167/Android',
Referer: 'yytingting.com',
ClientVersion: '6.3.4.0',
}
const signed = (path: string, params: Record<string, string>) => {
const q: Record<string, string> = { token: GUEST_TOKEN, imei: GUEST_IMEI, nwt: '1', ...params }
const qs = Object.keys(q).sort().map(k => `${k}=${q[k]}`).join('&')
q.sc = crypto.createHash('md5').update(path + '?' + qs + SECRET).digest('hex')
return `${DOMAIN}${path}?${Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}`
}
/** App API GET(游客态,仅搜索可用;章节/取流会 482 需登录) */
export const appApiGet = async(path: string, params: Record<string, string>): Promise<any> => {
const { statusCode, body } = await httpFetch(signed(path, params), { headers: APP_HEADERS, timeout: 15000 }).promise as any
if (statusCode != 200) throw new Error(`懒人 App API HTTP ${statusCode}`)
return body ?? {}
}
export default { appApiGet }
+51
View File
@@ -0,0 +1,51 @@
// 懒人听书登录(web 通道 m.lrts.me/ajax/logon)
// 参考社区下载器(yoval/lrts 等):POST form {account, pwd: base64(password)},
// 成功响应 json.phone 非空,Set-Cookie 种下会话;取流接口必须携带该会话
// (免费章节匿名也返回"收费章节未购买",2026-10-02 实测)。
import { httpFetch } from '../common/request.ts'
import { getCredentials, setCredentials } from '../auth/store.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const LOGON_URL = 'https://m.lrts.me/ajax/logon'
/** 账号密码登录(成功保存会话 Cookie) */
export const login = async({ account, password }: { account: string, password: string }) => {
const form = `account=${encodeURIComponent(account)}&pwd=${Buffer.from(String(password), 'utf8').toString('base64')}`
const { statusCode, body, headers } = await httpFetch(LOGON_URL, {
method: 'post',
headers: {
'User-Agent': UA_MI,
Referer: 'https://m.lrts.me/',
'Content-Type': 'application/x-www-form-urlencoded',
},
body: form,
timeout: 15000,
}).promise as any
const b = body ?? {}
if (statusCode != 200 || !b.phone) {
const err = new Error(b.msg || '登录失败(账号或密码错误)')
;(err as any).status = 401
throw err
}
const setCookies = (headers as any)?.['set-cookie'] ?? []
const cookie = (Array.isArray(setCookies) ? setCookies : [setCookies])
.map((c: string) => String(c).split(';')[0].trim())
.filter(Boolean)
.join('; ')
if (!cookie) throw new Error('登录成功但未返回会话 Cookie')
setCredentials('lr', { cookie, phone: String(b.phone) })
return { logged: true, phone: String(b.phone) }
}
/** 登录会话 Cookie(未登录返回空串) */
export const getLrCookie = (): string => (getCredentials('lr') as any)?.cookie || ''
/** 登录状态 */
export const status = () => {
const cred = getCredentials('lr') as { cookie?: string, phone?: string, updatedAt?: string } | null
return cred?.cookie
? { logged: true, phone: cred.phone, updatedAt: cred.updatedAt }
: { logged: false }
}
export default { login, getLrCookie, status }
+53
View File
@@ -0,0 +1,53 @@
// 懒人听书(lrts.me)平台模块
// 匿名可用面(2026-10-02 实测):搜索(App 通道游客态)/ 章节列表(web 免签)。
// 取流需登录(web getPlayPath,免费章节也要会话):POST /login/lr 账号密码登录。
// 章节 songmid 为复合 id "<bookId>:<section>";有声内容不参与聚合(noAggregate)。
import lrApi from '../apis/lr.ts'
import musicSearch from './musicSearch.ts'
import { getBookMenu } from './menu.ts'
const lr = {
musicSearch,
getMusicUrl(songInfo, type) {
return lrApi.getMusicUrl(songInfo, type)
},
getPic() {
// 书籍封面无独立免签接口;搜索结果的 img 字段已带封面(这里返回空由调用方回退)
return Promise.resolve(null)
},
album: {
async getAlbumDetail(bookId: string, page = 1, limit = 30) {
const { total, list } = await getBookMenu(bookId, page, limit)
return {
info: { name: `书籍 ${bookId}`, total },
list: list.slice(0, limit).map((item: any) => ({
songmid: `${bookId}:${item.section}`,
name: item.name || `第${item.section}节`,
singer: '',
albumId: String(bookId),
albumName: '',
interval: 0,
payPlay: String(item.listenPrice) !== '0',
img: null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
total,
limit,
allPage: Math.ceil(total / 30) || page,
source: 'lr' as const,
}
},
},
getMusicDetailPageUrl(songInfo) {
const bookId = String(songInfo.songmid ?? '').split(':')[0]
return `https://m.lrts.me/book/${bookId}`
},
}
// login 一并导出:/login/lr 路由直接引用 sdk/lr/auth.ts,SDK 使用者也需要能拿到登录能力
export { login, status } from './auth.ts'
export default lr
+21
View File
@@ -0,0 +1,21 @@
// 懒人听书章节列表(web 通道,匿名免签)
// 接口: https://m.lrts.me/ajax/getBookMenu?bookId=&pageNum=&pageSize=&sortType=0
// 实测(2026-10-02)匿名可用;章节条目 {section, name, listenPrice, state}
// 注意:专辑类内容用 getAlbumAudios(entityType=2),本实现仅收书(entityType=3)
import { httpFetch } from '../common/request.ts'
const UA_MI = 'Mozilla/5.0 (Linux; U; Android 4.1.1; zh-cn; MI2 Build/JRO03L) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30 XiaoMi/MiuiBrowser/1.0'
const MENU_URL = 'https://m.lrts.me/ajax/getBookMenu'
export const getBookMenu = async(bookId: string, page = 1, limit = 30) => {
const { statusCode, body } = await httpFetch(`${MENU_URL}?bookId=${bookId}&pageNum=${page}&pageSize=${Math.max(30, limit)}&sortType=0`, {
headers: { 'User-Agent': UA_MI, Referer: 'https://m.lrts.me/' },
timeout: 15000,
}).promise as any
if (statusCode != 200) throw new Error(`获取章节失败(HTTP ${statusCode})`)
const b = body ?? {}
if (b.status === 1 || !b.list) throw new Error(b.msg || '获取章节失败')
return { total: Number(b.sections ?? 0), list: b.list }
}
export default { getBookMenu }
+41
View File
@@ -0,0 +1,41 @@
// 懒人听书搜索(App 通道,游客匿名)
// 接口: /yyting/search/searchBatch.action?keyWord=&searchOption=1&pageSize=&type=0&q=2150
// 实测(2026-10-02)匿名可用;结果分 data.bookResult/albumResult/folderResult/...
// 书籍条目字段:id(baseEntityId 同值)/name/announcer/cover
import { appApiGet } from './appApi.ts'
export const search = async(keyword: string, page = 1, limit = 20) => {
const body = await appApiGet('/yyting/search/searchBatch.action', {
keyWord: keyword,
searchOption: '1',
pageSize: String(limit),
type: String(page - 1),
q: '2150',
})
if (body.apiStatus != null && body.apiStatus !== 0) throw new Error(body.msg || `搜索失败(code ${body.apiStatus})`)
const books = body?.data?.bookResult?.list ?? []
const total = Number(body?.data?.bookResult?.count ?? books.length)
return {
allPage: Math.ceil(total / limit) || page,
total,
limit,
source: 'lr' as const,
list: books.map((item: any) => ({
songmid: String(item.id ?? item.baseEntityId),
name: item.name || '',
singer: item.announcer || item.author || '',
albumId: '',
albumName: '',
interval: 0,
payPlay: false,
img: item.cover || null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
}
}
export default { search }
+55
View File
@@ -0,0 +1,55 @@
// 猫耳FM 广播剧集列表(web 通道,匿名免签)
// 接口: https://www.missevan.com/dramaapi/getdramaepisodes?drama_id=&season=&page=&order=0&pageSize=
// 结果在 info.episodes.episode[](name/sound_id/pay_type/vip);剧名在 info.drama.name
// 注意:getdrama(不带 type=2 时)的 info.episodes.episode 同样可用(MusicFree 插件用法),本接口支持翻页故用之
import { httpFetch } from '../common/request.ts'
import { formatPlayTime } from '../index.ts'
const EPISODES_URL = 'https://www.missevan.com/dramaapi/getdramaepisodes'
export const getAlbumDetail = async(dramaIdRaw: string, page = 1, limit = 30) => {
// 搜索结果的剧条目带 d: 前缀(songmid="d:<id>"),这里剥掉
const dramaId = dramaIdRaw.startsWith('d:') ? dramaIdRaw.slice(2) : dramaIdRaw
if (!/^\d+$/.test(dramaId)) throw new Error('剧 id 无效')
const { statusCode, body } = await httpFetch(
`${EPISODES_URL}?drama_id=${dramaId}&season=1&page=${page}&order=0&pageSize=${Math.max(30, limit)}`,
{
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
Referer: `https://www.missevan.com/mdrama/drama/${dramaId}`,
Accept: 'application/json',
},
timeout: 15000,
},
).promise as any
if (statusCode != 200 || body?.success === false) {
throw new Error(body?.info || `获取剧集失败(HTTP ${statusCode})`)
}
const info = body?.info ?? {}
const drama = info.drama ?? {}
const episodes = info.episodes?.episode ?? []
return {
info: { name: drama.name || String(dramaId), total: episodes.length },
list: episodes.slice(0, limit).map((item: any) => ({
songmid: String(item.sound_id),
name: item.name || '',
singer: drama.name || '',
albumId: String(dramaId),
albumName: drama.name || '',
interval: item.duration ? formatPlayTime(Math.round(Number(item.duration) / 1000)) : 0,
payPlay: Number(item.pay_type) > 0,
img: drama.cover || null,
lrc: '',
lrcx: '',
types: [],
_types: {},
disabled: false,
})),
total: episodes.length,
limit,
allPage: 1,
source: 'me' as const,
}
}
export default { getAlbumDetail }
+35
View File
@@ -0,0 +1,35 @@
// 猫耳FM(MissEvan,ACG 广播剧/音声平台)平台模块
// 匿名可用面(2026-10-02 实测):搜索 / 剧集列表 / 免费声音明文直链 / 封面。
// 取流走 App API 签名通道(app.missevan.com,HMAC-SHA256(appsecret)),
// 返回 maoercdn 免签名直链——网页版 hls.m3u8 是 Widevine 加密流,勿用 web soundurl。
// 付费声音需 POST /login/me 绑定含 token 的 cookie。
// 有声内容不参与聚合搜索与跨平台匹配(noAggregate),仅按 source=me 直查。
import meApi from '../apis/me.ts'
import musicSearch from './musicSearch.ts'
import { getSoundInfo } from './musicInfo.ts'
import { getAlbumDetail } from './album.ts'
import { getCredentials } from '../auth/store.ts'
const me = {
musicSearch,
getMusicUrl(songInfo, type) {
return meApi.getMusicUrl(songInfo, type)
},
getPic(songInfo) {
return getSoundInfo(songInfo.songmid).then((info: any) => info?.front_cover || null)
},
album: { getAlbumDetail },
getMusicDetailPageUrl(songInfo) {
return `https://www.missevan.com/sound/${songInfo.songmid}`
},
}
/** 登录状态(凭据存在性判断) */
export const status = () => {
const cred = getCredentials('me') as { cookie?: string, updatedAt?: string } | null
return cred?.cookie
? { logged: true, updatedAt: cred.updatedAt }
: { logged: false }
}
export default me
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more