diff --git a/sdk/index.ts b/sdk/index.ts index b576300..90df692 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -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> => { const musicName = trimStr(name) const tasks: Array> = [] 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 = (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 => { - 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() diff --git a/src/openapi/components.ts b/src/openapi/components.ts index 6eb8bb3..e1b590f 100644 --- a/src/openapi/components.ts +++ b/src/openapi/components.ts @@ -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, }, diff --git a/src/routes.ts b/src/routes.ts index 757f0b6..50fc829 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -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 }> = [] + 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) diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 1b3a11e..8982ee3 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -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')