Compare commits
2
Commits
0f9cff2a08
...
e796e622f2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e796e622f2 | ||
|
|
b3e1cc5f67 |
No files matched your search
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)
|
- **多平台聚合**:酷我(kw)、酷狗(kg)、QQ音乐(tx)、网易云(wy)、汽水音乐(qs)
|
||||||
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
- **直连取流**:各平台播放地址获取(不依赖第三方 API 服务)
|
||||||
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单
|
- **完整链路**:搜索、播放地址、歌词(含各平台加密歌词解码)、封面、榜单、热搜、歌单
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ 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 省略则聚合所有平台) |
|
||||||
| `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) |
|
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit)。取流链:外部音源(启用时)→ SDK 直连 → 酷我兜底(跨平台匹配,响应 `via` 字段标记实际命中通道) |
|
||||||
|
| `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` | 获取封面 |
|
||||||
| `GET /boards/:source` | 榜单列表 |
|
| `GET /boards/:source` | 榜单列表 |
|
||||||
@@ -46,6 +47,7 @@ pnpm typecheck # 仅类型检查,不运行
|
|||||||
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
|
| `POST /login/kg` + `userid` | 绑定酷狗 token(App 抓包获取,服务端验证) |
|
||||||
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
|
| `POST /login/tx/qr` → `POST /login/tx/qr/check` | QQ 音乐扫码登录(QQ/微信扫一扫,扫码后轮询完成登录) |
|
||||||
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
|
| `POST /login/tx` | 绑定 QQ 音乐 cookie(y.qq.com 复制,可选 App 抓包字段供自动续期) |
|
||||||
|
| `POST /login/qs/qr` → `POST /login/qs/qr/check` | 汽水音乐扫码登录(**抖音 App** 扫码,登录后解锁完整曲目与无损取流) |
|
||||||
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
|
| `GET /login/status` | 登录状态(登录后取流/请求自动携带凭据) |
|
||||||
| `GET /sources` | 平台列表(音质与能力) |
|
| `GET /sources` | 平台列表(音质与能力) |
|
||||||
| `GET /health` | 健康检查 |
|
| `GET /health` | 健康检查 |
|
||||||
@@ -58,11 +60,14 @@ 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` 登录后走解密流代理取完整曲目
|
||||||
|
|
||||||
|
`/url` 在外部音源与平台直连都失败时,自动降级到**酷我**兜底:按歌名/歌手/时长匹配同一首歌后走 kw 通道(匿名可拿无损)。kg 可由 hash 反查歌曲信息;其它平台需要兜底时给 `/url` 传 `name`/`singer`/`interval` 查询参数。
|
||||||
|
|
||||||
## 外部音源
|
## 外部音源
|
||||||
|
|
||||||
设置 `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,8 +94,8 @@ 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/ 脚本)
|
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/
|
||||||
@@ -98,15 +103,15 @@ src/ HTTP 应用层(Fastify 服务器,全部 TypeScript 源
|
|||||||
openapi/
|
openapi/
|
||||||
components.ts OpenAPI 共享 schema(与路由校验同源)
|
components.ts OpenAPI 共享 schema(与路由校验同源)
|
||||||
ui.ts ReDoc 页面模板
|
ui.ts ReDoc 页面模板
|
||||||
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy 四平台)
|
sdk/ 音乐聚合 SDK(仅官方平台:平台模块 + 官方直连取流 + 凭据 + 基础设施;kw/kg/tx/wy/qs 五平台)
|
||||||
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
|
index.ts 平台聚合入口(通用工具转发,实现在 common/utils.ts)
|
||||||
apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy)
|
apis/ 各平台官方 URL 直连取流实现(kw/kg/tx/wy/qs)
|
||||||
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/ 平台模块(搜索/歌词/榜单/歌单等;qs 含字节系参数签名与音频解密 crypto/)
|
||||||
test/
|
test/
|
||||||
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
|
smoke-test.ts 冒烟测试(自动启动/关闭本地服务器,验证各平台接口)
|
||||||
source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
|
source-simple-test.ts 简单 API 音源脚本加载器测试(离线)
|
||||||
@@ -116,6 +121,7 @@ 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` 抖音扫码登录,取流走 `/stream/qs/:id` 解密流代理(一次性下载解密回明文,不支持 Range)。
|
||||||
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
|
- 平台接口随时可能变更,遇失效时可对照 `sdk/apis/` 内的接口文档调整。
|
||||||
+7
-8
@@ -86,19 +86,17 @@ 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 ||
|
if (only ? source.id !== only : source.id === excludeSource) continue
|
||||||
!source.module.musicSearch
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
tasks.push(
|
tasks.push(
|
||||||
source.module.musicSearch
|
source.module.musicSearch
|
||||||
.search(`${musicName} ${singer || ''}`.trim(), 1, limit)
|
.search(`${musicName} ${singer || ''}`.trim(), 1, limit)
|
||||||
@@ -120,11 +118,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()
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
MusicUrlResult: {
|
MusicUrlResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
type: { type: 'string', example: '320k' },
|
type: { type: 'string', example: '320k', description: '实际命中的音质(降级链可能低于请求音质)' },
|
||||||
url: { type: 'string', description: '播放地址' },
|
url: { type: 'string', description: '播放地址' },
|
||||||
|
via: { type: 'string', example: 'kw', description: '实际命中通道:script=外部音源脚本 / 平台 id / kw=酷我兜底;直接命中时不返回该字段' },
|
||||||
},
|
},
|
||||||
additionalProperties: true,
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
|
|||||||
+60
-17
@@ -4,6 +4,7 @@
|
|||||||
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
|
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
|
||||||
|
|
||||||
import { notFound, upstream } from './http/api-error.ts'
|
import { notFound, upstream } from './http/api-error.ts'
|
||||||
|
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
|
||||||
|
|
||||||
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
|
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
|
||||||
|
|
||||||
@@ -128,39 +129,81 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
|
|
||||||
const urlHandler = async(request) => {
|
const urlHandler = async(request) => {
|
||||||
const { source, id } = request.params
|
const { source, id } = request.params
|
||||||
const { quality, albumId } = request.query
|
const { quality, albumId, name, singer, interval } = request.query
|
||||||
const m = getSource(musicSdk, source, 'getMusicUrl')
|
const m = getSource(musicSdk, source, 'getMusicUrl')
|
||||||
// 音源模式下服务端把取流重定向到外部音源脚本;未挂载时走 SDK 官方直连接口
|
|
||||||
const impl = fastify.sourceApi?.[source] ?? m
|
|
||||||
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
|
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
|
||||||
let result
|
|
||||||
try {
|
// 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配)
|
||||||
result = unwrap(impl.getMusicUrl(songInfo, quality))
|
const finish = (res: any, via: string) => {
|
||||||
} catch (err: any) {
|
if (!res?.url) throw new Error('获取播放地址失败')
|
||||||
throw upstream(err.message)
|
|
||||||
}
|
|
||||||
return result.then(res => {
|
|
||||||
if (!res?.url) throw upstream('获取播放地址失败')
|
|
||||||
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器)
|
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器)
|
||||||
const url = String(res.url).startsWith('/')
|
const url = String(res.url).startsWith('/')
|
||||||
? `${request.protocol}://${request.headers.host}${res.url}`
|
? `${request.protocol}://${request.headers.host}${res.url}`
|
||||||
: res.url
|
: res.url
|
||||||
return { type: quality, url }
|
// type 用实际音质(kw 等通道可能降级);via 标记兜底链中真正命中的通道
|
||||||
}).catch(err => {
|
return { type: res.type || quality, url, ...(via !== source ? { via } : {}) }
|
||||||
throw err.status ? err : upstream(err.message)
|
}
|
||||||
})
|
|
||||||
|
const attempts: Array<{ via: string, run: () => Promise<any> }> = []
|
||||||
|
if (fastify.sourceApi?.[source]) {
|
||||||
|
attempts.push({ via: 'script', run: () => unwrap(fastify.sourceApi[source].getMusicUrl(songInfo, quality)) })
|
||||||
|
}
|
||||||
|
attempts.push({ via: source, run: () => unwrap(m.getMusicUrl(songInfo, quality)) })
|
||||||
|
|
||||||
|
const errors: string[] = []
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
try {
|
||||||
|
return finish(await attempt.run(), attempt.via)
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`${attempt.via}: ${err?.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 酷我兜底:匿名可拿真无损的通道,按歌名/歌手/时长匹配同一首歌后取流
|
||||||
|
if (source !== 'kw') {
|
||||||
|
try {
|
||||||
|
let meta: { name?: string, singer?: string, interval?: string } | null = name ? { name, singer, interval } : null
|
||||||
|
if (!meta && source === 'kg') {
|
||||||
|
// kg 的 id 是 hash,可反查出歌名/歌手/时长
|
||||||
|
const info = await getMusicInfo(id)
|
||||||
|
if (info?.name) meta = { name: String(info.name), singer: info.singer ? String(info.singer) : undefined, interval: info.interval ? String(info.interval) : undefined }
|
||||||
|
}
|
||||||
|
if (meta?.name) {
|
||||||
|
const [best] = await musicSdk.findMusic({ ...meta, only: 'kw' })
|
||||||
|
if (best) {
|
||||||
|
const res = await unwrap(musicSdk.kw.getMusicUrl({ songmid: best.songmid, hash: best.songmid, _types: {} }, quality))
|
||||||
|
return finish(res, 'kw')
|
||||||
|
}
|
||||||
|
errors.push('kw 兜底: 未匹配到同名歌曲')
|
||||||
|
} else {
|
||||||
|
errors.push('kw 兜底: 缺少歌名信息(传 name/singer/interval 查询参数可启用)')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
errors.push(`kw 兜底: ${err?.message || err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw upstream(`取流失败(${errors.join(';')})`)
|
||||||
}
|
}
|
||||||
|
|
||||||
fastify.get('/url/:source/:id', {
|
fastify.get('/url/:source/:id', {
|
||||||
schema: {
|
schema: {
|
||||||
summary: '获取播放地址',
|
summary: '获取播放地址',
|
||||||
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash。',
|
description: 'id 为平台歌曲 id:kw/tx/wy 用 songmid,kg 用 hash,qs 用 track id。' +
|
||||||
|
'取流按「外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底」顺序尝试,' +
|
||||||
|
'酷我兜底按歌名/歌手/时长匹配同一首歌(匿名可用无损),kg 可由 hash 反查歌曲信息,' +
|
||||||
|
'其它平台需要兜底时传 name/singer/interval。响应中的 via 字段标记实际命中通道(仅兜底时出现)。',
|
||||||
tags: ['歌曲资源'],
|
tags: ['歌曲资源'],
|
||||||
params: params({
|
params: params({
|
||||||
source: pSource('url'),
|
source: pSource('url'),
|
||||||
id: pId,
|
id: pId,
|
||||||
}),
|
}),
|
||||||
querystring: qs({ quality: pQuality, albumId: { type: 'string', description: '专辑 id' } }),
|
querystring: qs({
|
||||||
|
quality: pQuality,
|
||||||
|
albumId: { type: 'string', description: '专辑 id' },
|
||||||
|
name: { type: 'string', description: '歌名(酷我兜底匹配用)' },
|
||||||
|
singer: { type: 'string', description: '歌手名(酷我兜底匹配用)' },
|
||||||
|
interval: { type: 'string', description: '时长 mm:ss(酷我兜底匹配用)', example: '04:17' },
|
||||||
|
}),
|
||||||
response: responses('MusicUrlResult'),
|
response: responses('MusicUrlResult'),
|
||||||
},
|
},
|
||||||
}, urlHandler)
|
}, urlHandler)
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ await test('URL kw 128k 免费歌', async() => {
|
|||||||
const r = await get('/url/kw/151826618?quality=128k')
|
const r = await get('/url/kw/151826618?quality=128k')
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
if (!r.data?.url) throw new Error('无 url')
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
if (r.data.via !== undefined) throw new Error(`直接命中不应有 via 字段,实际 ${r.data.via}`)
|
||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -193,6 +194,24 @@ await test('URL qs 试听 128k', async() => {
|
|||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 取流链兜底:平台直连失败后按 name/singer 匹配酷我(via=kw 标记实际命中通道)
|
||||||
|
await test('URL wy 坏 id → 酷我兜底(via=kw)', async() => {
|
||||||
|
const r = await get('/url/wy/not-exist-id?quality=128k&name=晴天&singer=周杰伦&interval=04:29')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (r.data?.via !== 'kw') throw new Error(`期望 via=kw,实际 ${JSON.stringify(r.data)}`)
|
||||||
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
return r.data.url.slice(0, 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 坏 id 且未传歌名:kg hash 反查也失败时,应返回聚合各阶段错误的 502 而非裸错误
|
||||||
|
await test('URL kg 坏 hash 无歌名 → 502 聚合错误', async() => {
|
||||||
|
const r = await getRaw('/url/kg/00000000000000000000000000000000?quality=128k')
|
||||||
|
if (r.status !== 502) throw new Error(`期望 502,实际 ${r.status}`)
|
||||||
|
const msg = JSON.parse(r.body).msg
|
||||||
|
if (!msg.includes('取流失败')) throw new Error(`错误信息应为取流失败聚合,实际 ${msg}`)
|
||||||
|
return msg
|
||||||
|
})
|
||||||
|
|
||||||
// 歌词
|
// 歌词
|
||||||
await test('歌词 kw', async() => {
|
await test('歌词 kw', async() => {
|
||||||
const r = await get('/lyric/kw/28423286')
|
const r = await get('/lyric/kw/28423286')
|
||||||
|
|||||||
Reference in new issue
Block a user