feat: 平台注册与路由收尾——9 平台(含 xm/me/qt/lr 有声)、复合 id 约定、64k 档位、测试与文档

This commit is contained in:
snowy-yang committed 2026-10-02 19:43:21 +08:00
1 parent 6fb866e343
commit 1fec828547
7 files changed
+307 -14

No files matched your search

+14 -5
View File
@@ -4,7 +4,7 @@
## 功能 ## 功能
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs) - **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)+ 有声平台:喜马拉雅(xm)、猫耳FM(me)、蜻蜓FM(qt)、懒人听书(lr)(有声平台不参与聚合搜索)
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务) - **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单 - **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单
@@ -26,9 +26,9 @@ pnpm typecheck # 仅类型检查,不运行
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) | | `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有音乐平台,xm 不参与聚合需显式指定) |
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | | `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) | | `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
| `GET /stream/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) | | `GET /stream/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) |
| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | | `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) |
| `GET /pic/:source/:id` | 获取封面 | | `GET /pic/:source/:id` | 获取封面 |
@@ -39,15 +39,20 @@ pnpm typecheck # 仅类型检查,不运行
| `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=1&limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) | | `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx) | | `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/kw/tx/xm) |
| `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** 扫码,登录后解锁完整曲目与无损取流) | | `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(**抖音 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,懒人取流要求登录态,免费章节同样) |
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) | | `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
@@ -62,6 +67,10 @@ pnpm typecheck # 仅类型检查,不运行
- **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`) - **kw / tx / wy**:`songmid`(如 `228908`、`0039MnYb0qxYhV`)
- **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流 - **kg**:`hash`(搜索结果的 `hash` 字段);VIP/付费歌需 `/login/kg` 登录后取流
- **qs**:track id(搜索结果的 `songmid` 字段);匿名仅 30s 试听,`/login/qs/qr` 登录后走解密流代理取完整曲目 - **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` 登录(免费章节同样要求登录态)
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。 `/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
@@ -94,7 +103,7 @@ export async function getMusicUrl({ source, id, songInfo, quality, signal }) {
src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码) src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源码)
server.ts HTTP 服务器入口(pnpm start 实际启动文件) server.ts HTTP 服务器入口(pnpm start 实际启动文件)
routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据) routes.ts 业务路由(JSON Schema 校验 + OpenAPI 元数据)
routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/token、tx 扫码/cookie、qs 抖音扫码) routes-login.ts 登录路由(wy 扫码/手机号、kg 扫码/短信/账密+风控验证/token、tx 扫码/cookie、qs 抖音扫码、xm/me cookie 绑定)
source.ts 外部音源候选链(SOURCE_ENABLED 时 /url 取流优先走 sources/ 脚本,失败回落 SDK) 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 进程内直载)
+22
View File
@@ -45,6 +45,28 @@ 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
+21 -2
View File
@@ -3,11 +3,19 @@ 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 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 { 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 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 type { PlatformModule, SearchResult, SongInfo } from './types.ts' import type { PlatformModule, SearchResult, SongInfo } from './types.ts'
import { import {
sizeFormate, sizeFormate,
@@ -22,17 +30,22 @@ 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: '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 },
] ]
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))
@@ -45,6 +58,10 @@ export const supportQuality: Record<string, string[]> = {
tx: txQualitys, tx: txQualitys,
wy: wyQualitys, wy: wyQualitys,
qs: qsQualitys, qs: qsQualitys,
xm: xmQualitys,
me: meQualitys,
qt: qtQualitys,
lr: lrQualitys,
} }
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)
@@ -96,6 +113,8 @@ export const searchMusic = async (
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 (!source.module.musicSearch) continue if (!source.module.musicSearch) continue
// 有声平台(noAggregate)仅在显式 only 指定时参与搜索
if (source.noAggregate && source.id !== only) continue
if (only ? source.id !== only : source.id === excludeSource) continue if (only ? source.id !== only : source.id === excludeSource) continue
tasks.push( tasks.push(
source.module.musicSearch source.module.musicSearch
+1 -1
View File
@@ -2,7 +2,7 @@
// 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。 // 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。
/** 音乐平台标识 */ /** 音乐平台标识 */
export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'xm' | 'me' | 'qt' | 'lr'
/** 音质标识(wav 仅部分接口声明使用) */ /** 音质标识(wav 仅部分接口声明使用) */
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav' export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
+120 -1
View File
@@ -5,6 +5,9 @@ import * as wyAuth from '../sdk/auth/wy.ts'
import * as kgAuth from '../sdk/auth/kg.ts' import * as kgAuth from '../sdk/auth/kg.ts'
import * as txAuth from '../sdk/auth/tx.ts' import * as txAuth from '../sdk/auth/tx.ts'
import * as qsPassport from '../sdk/qs/passport.ts' import * as qsPassport from '../sdk/qs/passport.ts'
import { status as xmStatus } from '../sdk/xm/index.ts'
import { status as meStatus } from '../sdk/me/index.ts'
import * as lrAuth from '../sdk/lr/auth.ts'
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts' import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
import { refreshAll } from './refresh.ts' import { refreshAll } from './refresh.ts'
@@ -159,6 +162,59 @@ export const loginRoutes = async(fastify) => {
} }
}) })
fastify.post('/login/kg/password', {
schema: {
summary: '酷狗账号密码登录',
description: '触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):' +
'滑块类需浏览器完成图形验证(返回的 url/txappid 供前端加载腾讯验证码)后调 /login/kg/verify,' +
'再重试本接口;短信类(v_type=32)将收到的短信码先提交 /login/kg/verify,随后带 code 重试本接口完成登录。' +
'风控较严,频繁失败建议改用扫码或短信登录。',
tags: ['登录'],
body: {
type: 'object',
properties: {
username: { type: 'string' },
password: { type: 'string' },
code: { type: 'string', description: '短信验证码(20028 短信验证通过后重试登录时携带)' },
},
required: ['username', 'password'],
},
},
}, async(request, reply) => {
try {
return await kgAuth.passwordLogin(request.body)
} catch (err) {
return fail(reply, err)
}
})
fastify.post('/login/kg/verify', {
schema: {
summary: '提交酷狗风控验证结果',
description: '配合 /login/kg/password 的 needVerify 挑战使用:verifycode 为图形验证码凭证' +
'(格式 KGCodeXX|{ticket,randstr,txappid})或短信验证码。sid/edt 省略时服务端自动生成行为指纹。' +
'验证通过后按挑战类型重试登录(短信码带 code 重试 /login/kg/password)。',
tags: ['登录'],
body: {
type: 'object',
properties: {
eventid: { type: 'string' },
v_type: { type: 'integer', description: '验证类型(needVerify 返回的 v_type)' },
verifycode: { type: 'string' },
sid: { type: 'string' },
edt: { type: 'string' },
},
required: ['eventid', 'v_type', 'verifycode'],
},
},
}, async(request, reply) => {
try {
return await kgAuth.verifyUserInfo(request.body)
} catch (err) {
return fail(reply, err)
}
})
fastify.delete('/login/kg', { fastify.delete('/login/kg', {
schema: { summary: '退出酷狗登录', tags: ['登录'] }, schema: { summary: '退出酷狗登录', tags: ['登录'] },
}, async() => { }, async() => {
@@ -266,6 +322,66 @@ export const loginRoutes = async(fastify) => {
return { logged: false } return { logged: false }
}) })
// ---------- 喜马拉雅 ----------
fastify.post('/login/xm', {
schema: {
summary: '绑定喜马拉雅 cookie',
description: 'cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段),' +
'用于解锁 xm 付费/已购内容取流。无法自动续期,失效后需重新复制绑定。',
tags: ['登录'],
body: {
type: 'object',
properties: { cookie: { type: 'string' } },
required: ['cookie'],
},
},
}, async(request, reply) => {
const cookie = String(request.body.cookie || '')
if (!/1&_token=\S+/.test(cookie)) {
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 1&_token 字段' })
}
setCredentials('xm', { cookie })
return { logged: true }
})
fastify.delete('/login/xm', {
schema: { summary: '退出喜马拉雅登录', tags: ['登录'] },
}, async() => {
clearCredentials('xm')
return { logged: false }
})
// ---------- 猫耳FM ----------
fastify.post('/login/me', {
schema: {
summary: '绑定猫耳FM token',
description: 'cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段),' +
'用于解锁 me 付费声音取流(App API 签名通道会自动携带)。无法自动续期,失效后需重新绑定。',
tags: ['登录'],
body: {
type: 'object',
properties: { cookie: { type: 'string' } },
required: ['cookie'],
},
},
}, async(request, reply) => {
const cookie = String(request.body.cookie || '')
if (!/(?:^|;\s*)token=\S+/.test(cookie)) {
return reply.code(422).send({ code: 422, msg: 'cookie 需包含 token 字段' })
}
setCredentials('me', { cookie })
return { logged: true }
})
fastify.delete('/login/me', {
schema: { summary: '退出猫耳FM登录', tags: ['登录'] },
}, async() => {
clearCredentials('me')
return { logged: false }
})
// ---------- 通用 ---------- // ---------- 通用 ----------
fastify.post('/login/refresh', { fastify.post('/login/refresh', {
@@ -280,7 +396,7 @@ export const loginRoutes = async(fastify) => {
fastify.get('/login/status', { fastify.get('/login/status', {
schema: { schema: {
summary: '查看登录状态', summary: '查看登录状态',
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间)。', description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间),qs/xm/me/lr 为已存凭据。',
tags: ['登录'], tags: ['登录'],
}, },
}, async() => { }, async() => {
@@ -293,6 +409,9 @@ export const loginRoutes = async(fastify) => {
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false }, kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false }, tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
qs: qsPassport.status(), qs: qsPassport.status(),
xm: xmStatus(),
me: meStatus(),
lr: lrAuth.status(),
} }
}) })
} }
+5 -5
View File
@@ -6,7 +6,7 @@
import { notFound, upstream } from './http/api-error.ts' import { notFound, upstream } from './http/api-error.ts'
import { getMusicInfo } from '../sdk/kg/musicInfo.ts' import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit'] const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
/** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */ /** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */
const unwrap = (result: any): any => ( const unwrap = (result: any): any => (
@@ -65,7 +65,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/search', { fastify.get('/search', {
schema: { schema: {
summary: '搜索歌曲', summary: '搜索歌曲',
description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。', description: 'source 省略时聚合全部平台(排除 xm/me/qt/lr 有声平台)搜索,失败的平台会被跳过。',
tags: ['搜索'], tags: ['搜索'],
querystring: qs({ querystring: qs({
name: { type: 'string', description: '歌名', example: '稻香' }, name: { type: 'string', description: '歌名', example: '稻香' },
@@ -94,7 +94,7 @@ export const musicRoutes = async(fastify: any) => {
const lists = await Promise.all( const lists = await Promise.all(
musicSdk.sources musicSdk.sources
.filter(s => musicSdk[s.id]?.musicSearch) .filter(s => musicSdk[s.id]?.musicSearch && !(s as any).noAggregate)
.map(s => doSearch(s.id).catch(() => [])), .map(s => doSearch(s.id).catch(() => [])),
) )
return { source: 'all', list: lists.flat(), page, limit } return { source: 'all', list: lists.flat(), page, limit }
@@ -188,7 +188,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/url/:source/:id', { fastify.get('/url/:source/:id', {
schema: { schema: {
summary: '获取播放地址', summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs 用 track id。' + description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs/xm/me 用声音 id,qt 为 <channelId>:<programId>(见 /album/qt),lr 为 <bookId>:<section>(见 /album/lr)。' +
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' + '取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' + '酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。', '其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
@@ -408,7 +408,7 @@ export const musicRoutes = async(fastify: any) => {
fastify.get('/album/:source/:id', { fastify.get('/album/:source/:id', {
schema: { schema: {
summary: '获取专辑详情与曲目列表', summary: '获取专辑详情与曲目列表',
description: '支持 kg/kw/mg(见 /sources 的 album 能力位)。kw 单页固定 1000 条、limit 不生效。', description: '支持 kg/kw/tx/xm/me(见 /sources 的 album 能力位)。kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
tags: ['歌手专辑'], tags: ['歌手专辑'],
params: params({ source: pSource('album'), id: { type: 'string', description: '平台专辑 id(搜索结果中的 albumId)' } }), params: params({ source: pSource('album'), id: { type: 'string', description: '平台专辑 id(搜索结果中的 albumId)' } }),
querystring: qs({ page: pPage, limit: pLimit }), querystring: qs({ page: pPage, limit: pLimit }),
+124
View File
@@ -194,6 +194,76 @@ await test('URL qs 试听 128k', async() => {
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
}) })
// xm 匿名免费曲直链(12096669 = 苏荷酒吧我爱的人,免费,2026-10-02 实测可用)
await test('URL xm 免费曲', async() => {
const r = await get('/url/xm/12096669?quality=128k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url || !/xmcdn\.com/.test(r.data.url)) throw new Error('非 xmcdn 直链')
return `${r.data.type} ${r.data.url.slice(0, 60)}`
})
// me 匿名免费声音明文直链(1557130 = 穿堂惊掠琵琶声·总预告,App API 签名通道,2026-10-02 实测可用)
await test('URL me 免费声音', async() => {
const r = await get('/url/me/1557130?quality=128k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url || !/maoercdn\.com/.test(r.data.url)) throw new Error('非 maoercdn 直链')
return `${r.data.type} ${r.data.url.slice(0, 60)}`
})
// qt 匿名节目直链(304214:12302057 = 诡吹灯续·片花,audiostream 签名通道,2026-10-02 实测可用)
await test('URL qt 免费节目', async() => {
const r = await get('/url/qt/304214:12302057?quality=64k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url || !/audiostream\/redirect/.test(r.data.url)) throw new Error('非 audiostream 签名直链')
return `${r.data.type} ${r.data.url.slice(0, 70)}`
})
// qt/lr 直查 + 聚合排除
await test('搜索 qt 直查', async() => {
const r = await get(`/search?source=qt&name=${encodeURIComponent('郭德纲')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
await test('搜索 lr 直查', async() => {
const r = await get(`/search?source=lr&name=${encodeURIComponent('三体')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
// lr 未登录取流应返回明确错误(502 上游)
await test('URL lr 未登录 → 明确错误', async() => {
const r = await getRaw('/url/lr/50678018:1?quality=64k')
if (r.status !== 502) throw new Error(`期望 502,实际 ${r.status}`)
if (!JSON.parse(r.body).msg.includes('/login/lr')) throw new Error(`错误信息应引导登录,实际 ${JSON.parse(r.body).msg}`)
return JSON.parse(r.body).msg.slice(0, 40)
})
// me 有声内容:直查可用、聚合排除
await test('搜索 me 直查', async() => {
const r = await get(`/search?source=me&name=${encodeURIComponent('广播剧')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
// xm 有声内容:直查可用、聚合排除
await test('搜索 xm 直查', async() => {
const r = await get(`/search?source=xm&name=${encodeURIComponent('郭德纲')}&limit=3`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}`
})
await test('聚合搜索排除 xm', async() => {
const r = await get(`/search?name=${encodeURIComponent('郭德纲')}&limit=2`)
if (r.code != 0) throw new Error(r.msg)
if (r.data?.list?.some((item: any) => ['xm', 'me', 'qt', 'lr'].includes(item.source))) throw new Error('聚合结果不应包含有声平台')
return 'ok'
})
// 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道) // 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道)
await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => { await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => {
const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29') const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29')
@@ -357,6 +427,38 @@ await test('专辑详情 /album/tx', async() => {
return `${r.data.info.name}(${r.data.list.length} 首)` return `${r.data.info.name}(${r.data.list.length} 首)`
}) })
// xm 专辑(3595841 = 苏荷酒吧专辑,2026-10-02 实测可用)
await test('专辑详情 /album/xm', async() => {
const r = await get('/album/xm/3595841')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.info.total} 集)`
})
// me 剧集(25276 = 穿堂惊掠琵琶声,2026-10-02 实测可用)
await test('专辑详情 /album/me', async() => {
const r = await get('/album/me/25276')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list?.length) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.info.total} 集)`
})
// qt 频道(304214 = 诡吹灯续,2026-10-02 实测可用)
await test('专辑详情 /album/qt', async() => {
const r = await get('/album/qt/304214')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length || !String(r.data.list[0].songmid).includes(':')) throw new Error('响应不完整或缺少复合 id')
return `${r.data.list.length} 节目,首条 ${r.data.list[0].name}`
})
// lr 书籍章节(50678018 = 三体广播剧,2026-10-02 实测可用)
await test('专辑详情 /album/lr', async() => {
const r = await get('/album/lr/50678018')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length || !String(r.data.list[0].songmid).includes(':')) throw new Error('响应不完整或缺少复合 id')
return `${r.data.info.total} 节`
})
await test('参数校验 422(缺少 name)', async() => { await test('参数校验 422(缺少 name)', async() => {
const r = await getRaw('/search') const r = await getRaw('/search')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
@@ -473,6 +575,28 @@ await test('kg 假 token 拒绝 POST /login/kg', async() => {
return r.body?.msg return r.body?.msg
}) })
// xm cookie 绑定校验(缺 1&_token 拒绝)
await test('xm 坏 cookie 拒绝 POST /login/xm', async() => {
const r = await postJson('/login/xm', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
// me cookie 绑定校验(缺 token 拒绝)
await test('me 坏 cookie 拒绝 POST /login/me', async() => {
const r = await postJson('/login/me', { cookie: 'foo=bar; baz=1' })
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
return `status ${r.status}`
})
// xm 登录状态展示
await test('登录状态含 xm 字段', async() => {
const r = await get('/login/status')
if (r.code != 0) throw new Error(r.msg)
if (!('xm' in (r.data ?? {})) || !('logged' in r.data.xm)) throw new Error('xm 登录状态缺失')
return `xm.logged=${r.data.xm.logged}`
})
// 汇总 // 汇总
const passed = results.filter(r => r.ok).length const passed = results.filter(r => r.ok).length
const failed = results.filter(r => !r.ok) const failed = results.filter(r => !r.ok)