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:
snowy-yang committed 2026-10-01 22:54:00 +08:00
1 parent 0f9cff2a08
commit b3e1cc5f67
4 files changed
+88 -26

No files matched your search

+7 -8
View File
@@ -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()
+2 -1
View File
@@ -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
View File
@@ -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)
+19
View File
@@ -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')