feat(api): search/find 改 path 风格——/search/:source/:keyword[/:page] 与 /url 同构,find 支持单平台/all

This commit is contained in:
snowy-yang committed 2026-10-02 20:51:50 +08:00
1 parent cf0874fc15
commit baa34a501c
3 files changed
+89 -58

No files matched your search

+2 -2
View File
@@ -26,8 +26,8 @@ pnpm typecheck # 仅类型检查,不运行
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有音乐平台,xm 不参与聚合需显式指定) | | `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 |
| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | | `GET /find/:source/:keyword` | 精确匹配搜索(source 为平台 id 或 all;?singer= ?albumName= ?interval= 辅助匹配,all 时 ?excludeSource= 跳过平台) |
| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 64k/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) |
+66 -43
View File
@@ -61,71 +61,94 @@ const getSource = (musicSdk: any, name: string, capability?: string) => {
export const musicRoutes = async(fastify: any) => { export const musicRoutes = async(fastify: any) => {
const musicSdk = fastify.musicSdk const musicSdk = fastify.musicSdk
// ---------- 搜索 ---------- // ---------- 搜索(path 风格:/search/:source/:keyword[/:page],与 /url/:source/:id 同构) ----------
fastify.get('/search', { const searchHandler = async(request) => {
schema: { const { source, keyword, page } = request.params
summary: '搜索歌曲', const { singer, limit } = request.query
description: 'source 省略时聚合全部平台(排除 xm/me/qt/lr 有声平台)搜索,失败的平台会被跳过。', const pageNo = Number(page) || 1
tags: ['搜索'], const word = `${keyword} ${singer || ''}`.trim()
querystring: qs({
name: { type: 'string', description: '歌名', example: '稻香' },
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
source: pSource('search'),
page: pPage,
limit: pLimit,
}, ['name']),
response: responses('SearchResult', { notFoundDoc: false }),
},
}, async(request) => {
const { name, singer, source, page, limit } = request.query
if (source !== 'all') getSource(musicSdk, source, 'musicSearch')
const doSearch = (id) => musicSdk[id].musicSearch const doSearch = (id) => musicSdk[id].musicSearch
.search(`${name} ${singer || ''}`.trim(), page, limit) .search(word, pageNo, limit)
.then(r => r.list) .then(r => r.list)
if (source) {
getSource(musicSdk, source, 'musicSearch')
try { try {
return { source, list: await doSearch(source), page, limit } if (source !== 'all') {
} catch (err: any) { return { source, list: await doSearch(source), page: pageNo, limit }
throw upstream(err.message)
} }
} // 聚合:排除 xm/me/qt/lr 有声平台,失败的平台跳过
const lists = await Promise.all( const lists = await Promise.all(
musicSdk.sources musicSdk.sources
.filter(s => musicSdk[s.id]?.musicSearch && !(s as any).noAggregate) .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: pageNo, limit }
} catch (err: any) {
throw upstream(err.message)
}
}
const searchSchema = (summary: string, withPage: boolean) => ({
summary,
description: 'source 为平台 id 或 all(聚合,排除 xm/me/qt/lr 有声平台,失败的平台跳过)。' +
'path 直接跟搜索内容,第 1 页可省略页码段;歌名含空格/斜杠时请拆分——歌手走 singer 查询参数。',
tags: ['搜索'],
params: params({
source: pSource('search'),
keyword: { type: 'string', description: '搜索关键词(URL 编码的中文可直接使用)', example: '稻香' },
page: { type: 'integer', minimum: 1, description: '页码(省略时为第 1 页)', example: 2 },
}, withPage ? undefined : ['source', 'keyword']),
querystring: qs({
singer: { type: 'string', description: '歌手名(与关键词拼接搜索)', example: '周杰伦' },
limit: pLimit,
}),
response: responses('SearchResult', { notFoundDoc: false }),
}) })
fastify.get('/find', { fastify.get('/search/:source/:keyword', { schema: searchSchema('搜索歌曲(第 1 页)', false) }, searchHandler)
schema: { fastify.get('/search/:source/:keyword/:page', { schema: searchSchema('搜索歌曲(指定页)', true) }, searchHandler)
summary: '跨平台精确匹配歌曲',
description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。', const findHandler = async(request) => {
tags: ['搜索'], const { source, keyword } = request.params
querystring: qs({ const { singer, albumName, interval, excludeSource } = request.query
name: { type: 'string', description: '歌名', example: '稻香' },
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
albumName: { type: 'string', description: '专辑名', example: '魔杰座' },
interval: { type: 'string', description: '时长(mm:ss),用于时长校验', example: '03:43' },
excludeSource: { type: 'string', description: '聚合结果中跳过该平台', example: 'kw' },
}, ['name']),
response: responses('FindResult', { notFoundDoc: false }),
},
}, async(request) => {
const { name, singer, albumName, interval, excludeSource } = request.query
try { try {
return { return {
list: await musicSdk.findMusic({ name, singer, albumName, interval, source: excludeSource }), list: await musicSdk.findMusic({
name: keyword,
singer,
albumName,
interval,
...(source !== 'all' ? { only: source } : {}),
...(source === 'all' && excludeSource ? { source: excludeSource } : {}),
}),
} }
} catch (err: any) { } catch (err: any) {
throw upstream(err.message) throw upstream(err.message)
} }
}
const findSchema = (summary: string) => ({
summary,
description: '按歌手、歌名、专辑、时长综合排序的精确匹配。source 为平台 id(只在单平台内匹配)或 all(跨平台聚合,' +
'可用 excludeSource 跳过某平台;聚合自动排除 xm/me/qt/lr 有声平台)。',
tags: ['搜索'],
params: params({
source: { type: 'string', description: '平台 id 或 all', example: 'all' },
keyword: { type: 'string', description: '歌名', example: '稻香' },
}),
querystring: qs({
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
albumName: { type: 'string', description: '专辑名', example: '魔杰座' },
interval: { type: 'string', description: '时长(mm:ss),用于时长校验', example: '03:43' },
excludeSource: { type: 'string', description: 'source=all 时跳过该平台', example: 'kw' },
}),
response: responses('FindResult', { notFoundDoc: false }),
}) })
fastify.get('/find/:source/:keyword', { schema: findSchema('精确匹配歌曲') }, findHandler)
// ---------- 歌曲资源(路径参数风格) ---------- // ---------- 歌曲资源(路径参数风格) ----------
const urlHandler = async(request) => { const urlHandler = async(request) => {
+21 -13
View File
@@ -130,7 +130,7 @@ if (externalServer) {
// 搜索 // 搜索
for (const source of ['kw', 'kg', 'tx', 'wy', 'qs']) { for (const source of ['kw', 'kg', 'tx', 'wy', 'qs']) {
await test(`搜索 ${source}`, async() => { await test(`搜索 ${source}`, async() => {
const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`) const r = await get(`/search/${source}/${encodeURIComponent('稻香')}?limit=2`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表') if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list[0].name} / ${r.data.list[0].singer}` return `${r.data.list[0].name} / ${r.data.list[0].singer}`
@@ -138,7 +138,7 @@ for (const source of ['kw', 'kg', 'tx', 'wy', 'qs']) {
} }
await test('聚合搜索', async() => { await test('聚合搜索', async() => {
const r = await get(`/search?name=${encodeURIComponent('稻香')}&limit=2`) const r = await get(`/search/all/${encodeURIComponent('稻香')}?limit=2`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表') if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 条结果` return `${r.data.list.length} 条结果`
@@ -220,14 +220,14 @@ await test('URL qt 免费节目', async() => {
// qt/lr 直查 + 聚合排除 // qt/lr 直查 + 聚合排除
await test('搜索 qt 直查', async() => { await test('搜索 qt 直查', async() => {
const r = await get(`/search?source=qt&name=${encodeURIComponent('郭德纲')}&limit=3`) const r = await get(`/search/qt/${encodeURIComponent('郭德纲')}?limit=3`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整') if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}` return `首条 ${r.data.list[0].name}`
}) })
await test('搜索 lr 直查', async() => { await test('搜索 lr 直查', async() => {
const r = await get(`/search?source=lr&name=${encodeURIComponent('三体')}&limit=3`) const r = await get(`/search/lr/${encodeURIComponent('三体')}?limit=3`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整') if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}` return `首条 ${r.data.list[0].name}`
@@ -243,7 +243,7 @@ await test('URL lr 未登录 → 明确错误', async() => {
// me 有声内容:直查可用、聚合排除 // me 有声内容:直查可用、聚合排除
await test('搜索 me 直查', async() => { await test('搜索 me 直查', async() => {
const r = await get(`/search?source=me&name=${encodeURIComponent('伪装学渣')}&limit=10`) const r = await get(`/search/me/${encodeURIComponent('伪装学渣')}?limit=10`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
const list = r.data?.list ?? [] const list = r.data?.list ?? []
if (!list.length) throw new Error('响应不完整') if (!list.length) throw new Error('响应不完整')
@@ -264,14 +264,14 @@ await test('URL me 剧条目 d: 前缀', async() => {
// xm 有声内容:直查可用、聚合排除 // xm 有声内容:直查可用、聚合排除
await test('搜索 xm 直查', async() => { await test('搜索 xm 直查', async() => {
const r = await get(`/search?source=xm&name=${encodeURIComponent('郭德纲')}&limit=3`) const r = await get(`/search/xm/${encodeURIComponent('郭德纲')}?limit=3`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('响应不完整') if (!r.data?.list?.length) throw new Error('响应不完整')
return `首条 ${r.data.list[0].name}` return `首条 ${r.data.list[0].name}`
}) })
await test('聚合搜索排除 xm', async() => { await test('聚合搜索排除 xm', async() => {
const r = await get(`/search?name=${encodeURIComponent('郭德纲')}&limit=2`) const r = await get(`/search/all/${encodeURIComponent('郭德纲')}?limit=2`)
if (r.code != 0) throw new Error(r.msg) 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('聚合结果不应包含有声平台') if (r.data?.list?.some((item: any) => ['xm', 'me', 'qt', 'lr'].includes(item.source))) throw new Error('聚合结果不应包含有声平台')
return 'ok' return 'ok'
@@ -362,7 +362,7 @@ await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
}) })
await test('跨平台匹配 /find', async() => { await test('跨平台匹配 /find', async() => {
const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`) const r = await get(`/find/all/${encodeURIComponent('稻香')}?singer=${encodeURIComponent('周杰伦')}`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表') if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list[0].name} / ${r.data.list[0].singer}` return `${r.data.list[0].name} / ${r.data.list[0].singer}`
@@ -472,12 +472,20 @@ await test('专辑详情 /album/lr', async() => {
return `${r.data.info.total} 节` return `${r.data.info.total} 节`
}) })
await test('参数校验 422(缺少 name)', async() => { await test('参数校验 422(非法页码)', async() => {
const r = await getRaw('/search') const r = await getRaw(`/search/kw/${encodeURIComponent('稻香')}/0`)
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
const body = JSON.parse(r.body) const body = JSON.parse(r.body)
if (body.code !== 422 || !body.msg) throw new Error('响应体格式错误') if (body.code !== 422 || !body.msg) throw new Error('响应体格式错误')
return body.msg return body.msg.slice(0, 60)
})
// path 风格分页:第 2 页显式页码段
await test('搜索分页 /search/kw/:kw/2', async() => {
const r = await get(`/search/kw/${encodeURIComponent('周杰伦')}/2?limit=5`)
if (r.code != 0) throw new Error(r.msg)
if (r.data?.page !== 2) throw new Error(`page 应为 2,实际 ${JSON.stringify(r.data?.page)}`)
return `第 ${r.data.page} 页 ${r.data.list.length} 条`
}) })
await test('参数校验 422(非法音质)', async() => { await test('参数校验 422(非法音质)', async() => {
@@ -501,7 +509,7 @@ await test('旧 query 路由已移除(404)', async() => {
}) })
await test('405 方法不允许(静态路由)', async() => { await test('405 方法不允许(静态路由)', async() => {
const r = await postRaw('/search') const r = await postRaw('/health')
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`) if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
if (!r.headers.allow) throw new Error('缺少 Allow 头') if (!r.headers.allow) throw new Error('缺少 Allow 头')
return `Allow: ${r.headers.allow}` return `Allow: ${r.headers.allow}`
@@ -533,7 +541,7 @@ await test('OpenAPI 规范 /openapi.json', async() => {
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
const spec = JSON.parse(r.body) const spec = JSON.parse(r.body)
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`) if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义') if (!spec.paths?.['/search/{source}/{keyword}'] || !spec.paths?.['/search/{source}/{keyword}/{page}']) throw new Error('缺少 /search path 风格定义')
if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义') if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义')
return `${Object.keys(spec.paths).length} 个路径` return `${Object.keys(spec.paths).length} 个路径`
}) })