diff --git a/README.md b/README.md index 3d133f3..aed3ed6 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ pnpm typecheck # 仅类型检查,不运行 | 接口 | 说明 | | --- | --- | -| `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有音乐平台,xm 不参与聚合需显式指定) | -| `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | +| `GET /search/:source/:keyword[/:page]` | 搜索歌曲:平台 id 或 all(聚合,排除 xm/me/qt/lr);第 1 页可省略页码段,下一页 `/search/kw/稻香/2`;?singer= 附加歌手、?limit= 每页条数 | +| `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 /stream/qs/:id` | 汽水音乐解密流代理(登录态取流地址,服务端解密回明文音频) | | `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/yrc) | diff --git a/src/routes.ts b/src/routes.ts index 5f6a3d8..a21e7c5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -61,71 +61,94 @@ const getSource = (musicSdk: any, name: string, capability?: string) => { export const musicRoutes = async(fastify: any) => { const musicSdk = fastify.musicSdk - // ---------- 搜索 ---------- + // ---------- 搜索(path 风格:/search/:source/:keyword[/:page],与 /url/:source/:id 同构) ---------- - fastify.get('/search', { - schema: { - summary: '搜索歌曲', - description: 'source 省略时聚合全部平台(排除 xm/me/qt/lr 有声平台)搜索,失败的平台会被跳过。', - tags: ['搜索'], - 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 + const searchHandler = async(request) => { + const { source, keyword, page } = request.params + const { singer, limit } = request.query + const pageNo = Number(page) || 1 + const word = `${keyword} ${singer || ''}`.trim() + if (source !== 'all') getSource(musicSdk, source, 'musicSearch') const doSearch = (id) => musicSdk[id].musicSearch - .search(`${name} ${singer || ''}`.trim(), page, limit) + .search(word, pageNo, limit) .then(r => r.list) - if (source) { - getSource(musicSdk, source, 'musicSearch') - try { - return { source, list: await doSearch(source), page, limit } - } catch (err: any) { - throw upstream(err.message) + try { + if (source !== 'all') { + return { source, list: await doSearch(source), page: pageNo, limit } } + // 聚合:排除 xm/me/qt/lr 有声平台,失败的平台跳过 + const lists = await Promise.all( + musicSdk.sources + .filter(s => musicSdk[s.id]?.musicSearch && !(s as any).noAggregate) + .map(s => doSearch(s.id).catch(() => [])), + ) + return { source: 'all', list: lists.flat(), page: pageNo, limit } + } catch (err: any) { + throw upstream(err.message) } + } - const lists = await Promise.all( - musicSdk.sources - .filter(s => musicSdk[s.id]?.musicSearch && !(s as any).noAggregate) - .map(s => doSearch(s.id).catch(() => [])), - ) - return { source: 'all', list: lists.flat(), page, limit } + 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', { - schema: { - summary: '跨平台精确匹配歌曲', - description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。', - tags: ['搜索'], - querystring: qs({ - 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 + fastify.get('/search/:source/:keyword', { schema: searchSchema('搜索歌曲(第 1 页)', false) }, searchHandler) + fastify.get('/search/:source/:keyword/:page', { schema: searchSchema('搜索歌曲(指定页)', true) }, searchHandler) + + const findHandler = async(request) => { + const { source, keyword } = request.params + const { singer, albumName, interval, excludeSource } = request.query try { 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) { 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) => { diff --git a/test/smoke-test.ts b/test/smoke-test.ts index 11c722b..88a88aa 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -130,7 +130,7 @@ if (externalServer) { // 搜索 for (const source of ['kw', 'kg', 'tx', 'wy', 'qs']) { 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.data?.list?.length) throw new Error('空列表') 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() => { - 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.data?.list?.length) throw new Error('空列表') return `${r.data.list.length} 条结果` @@ -220,14 +220,14 @@ await test('URL qt 免费节目', async() => { // qt/lr 直查 + 聚合排除 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.data?.list?.length) throw new Error('响应不完整') return `首条 ${r.data.list[0].name}` }) 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.data?.list?.length) throw new Error('响应不完整') return `首条 ${r.data.list[0].name}` @@ -243,7 +243,7 @@ await test('URL lr 未登录 → 明确错误', async() => { // me 有声内容:直查可用、聚合排除 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) const list = r.data?.list ?? [] if (!list.length) throw new Error('响应不完整') @@ -264,14 +264,14 @@ await test('URL me 剧条目 d: 前缀', async() => { // xm 有声内容:直查可用、聚合排除 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.data?.list?.length) throw new Error('响应不完整') return `首条 ${r.data.list[0].name}` }) 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.data?.list?.some((item: any) => ['xm', 'me', 'qt', 'lr'].includes(item.source))) throw new Error('聚合结果不应包含有声平台') return 'ok' @@ -362,7 +362,7 @@ await test('榜单详情 路径风格 /boards/:source/:boardId', 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.data?.list?.length) throw new Error('空列表') 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} 节` }) -await test('参数校验 422(缺少 name)', async() => { - const r = await getRaw('/search') +await test('参数校验 422(非法页码)', async() => { + const r = await getRaw(`/search/kw/${encodeURIComponent('稻香')}/0`) if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) const body = JSON.parse(r.body) 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() => { @@ -501,7 +509,7 @@ await test('旧 query 路由已移除(404)', 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.headers.allow) throw new Error('缺少 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}`) const spec = JSON.parse(r.body) 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('缺少路径参数路由定义') return `${Object.keys(spec.paths).length} 个路径` })