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 参数。
This commit is contained in:
1 parent
0f9cff2a08
commit
b3e1cc5f67
4 files changed
+88
-26
No files matched your search
+7
-8
@@ -86,19 +86,17 @@ interface MatchCandidate extends SongInfo {
|
||||
* @param params.name 歌名
|
||||
* @param params.singer 歌手
|
||||
* @param params.excludeSource 指定平台时,聚合结果会跳过该平台
|
||||
* @param params.only 只搜该平台(与 excludeSource 互斥,优先生效)
|
||||
* @param params.limit 单平台结果条数
|
||||
*/
|
||||
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>> => {
|
||||
const musicName = trimStr(name)
|
||||
const tasks: Array<Promise<SearchResult | null>> = []
|
||||
for (const source of SUPPORTED_SOURCES) {
|
||||
if (
|
||||
source.id === excludeSource ||
|
||||
!source.module.musicSearch
|
||||
)
|
||||
continue
|
||||
if (!source.module.musicSearch) continue
|
||||
if (only ? source.id !== only : source.id === excludeSource) continue
|
||||
tasks.push(
|
||||
source.module.musicSearch
|
||||
.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 (
|
||||
{ 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[]> => {
|
||||
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 fSinger = filterStr(sortSingle(singer)).toLowerCase()
|
||||
|
||||
@@ -67,8 +67,9 @@ export const COMPONENT_SCHEMAS = {
|
||||
MusicUrlResult: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
type: { type: 'string', example: '320k' },
|
||||
type: { type: 'string', example: '320k', description: '实际命中的音质(降级链可能低于请求音质)' },
|
||||
url: { type: 'string', description: '播放地址' },
|
||||
via: { type: 'string', example: 'kw', description: '实际命中通道:script=外部音源脚本 / 平台 id / kw=酷我兜底;直接命中时不返回该字段' },
|
||||
},
|
||||
additionalProperties: true,
|
||||
},
|
||||
|
||||
+60
-17
@@ -4,6 +4,7 @@
|
||||
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
|
||||
|
||||
import { notFound, upstream } from './http/api-error.ts'
|
||||
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
|
||||
|
||||
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
|
||||
|
||||
@@ -128,39 +129,81 @@ export const musicRoutes = async(fastify: any) => {
|
||||
|
||||
const urlHandler = async(request) => {
|
||||
const { source, id } = request.params
|
||||
const { quality, albumId } = request.query
|
||||
const { quality, albumId, name, singer, interval } = request.query
|
||||
const m = getSource(musicSdk, source, 'getMusicUrl')
|
||||
// 音源模式下服务端把取流重定向到外部音源脚本;未挂载时走 SDK 官方直连接口
|
||||
const impl = fastify.sourceApi?.[source] ?? m
|
||||
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
|
||||
let result
|
||||
try {
|
||||
result = unwrap(impl.getMusicUrl(songInfo, quality))
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
return result.then(res => {
|
||||
if (!res?.url) throw upstream('获取播放地址失败')
|
||||
|
||||
// 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配)
|
||||
const finish = (res: any, via: string) => {
|
||||
if (!res?.url) throw new Error('获取播放地址失败')
|
||||
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器)
|
||||
const url = String(res.url).startsWith('/')
|
||||
? `${request.protocol}://${request.headers.host}${res.url}`
|
||||
: res.url
|
||||
return { type: quality, url }
|
||||
}).catch(err => {
|
||||
throw err.status ? err : upstream(err.message)
|
||||
})
|
||||
// type 用实际音质(kw 等通道可能降级);via 标记兜底链中真正命中的通道
|
||||
return { type: res.type || quality, url, ...(via !== source ? { via } : {}) }
|
||||
}
|
||||
|
||||
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', {
|
||||
schema: {
|
||||
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: ['歌曲资源'],
|
||||
params: params({
|
||||
source: pSource('url'),
|
||||
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'),
|
||||
},
|
||||
}, urlHandler)
|
||||
|
||||
@@ -152,6 +152,7 @@ await test('URL kw 128k 免费歌', async() => {
|
||||
const r = await get('/url/kw/151826618?quality=128k')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
if (r.data.via !== undefined) throw new Error(`直接命中不应有 via 字段,实际 ${r.data.via}`)
|
||||
return r.data.url.slice(0, 60)
|
||||
})
|
||||
|
||||
@@ -193,6 +194,24 @@ await test('URL qs 试听 128k', async() => {
|
||||
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() => {
|
||||
const r = await get('/lyric/kw/28423286')
|
||||
|
||||
Reference in new issue
Block a user