feat(api): 新增歌手与专辑接口,修复各平台死代码中的多处隐藏 bug

This commit is contained in:
snowy-yang committed 2026-09-16 22:23:22 +08:00
1 parent d142dd6096
commit 23c7ad91e4
14 files changed
+317 -115

No files matched your search

+92
View File
@@ -102,6 +102,96 @@ export const COMPONENT_SCHEMAS = {
properties: { list: { type: 'array', items: ref('BoardItem') } },
additionalProperties: true,
},
SingerInfo: {
type: 'object',
properties: {
name: { type: 'string', description: '歌手名' },
desc: { type: ['string', 'null'], description: '歌手简介' },
avatar: { type: ['string', 'null'], description: '头像地址' },
gender: { type: ['string', 'null'], description: '性别(man/woman)' },
},
additionalProperties: true,
},
SingerInfoResult: {
type: 'object',
properties: {
source: { type: 'string' },
id: { type: 'string', description: '平台歌手 id' },
info: ref('SingerInfo'),
count: {
type: 'object',
properties: {
music: { type: ['integer', 'null'], description: '歌曲数' },
album: { type: ['integer', 'null'], description: '专辑数' },
},
additionalProperties: true,
},
},
additionalProperties: true,
},
SingerAlbumItem: {
type: 'object',
properties: {
id: { type: 'string', description: '专辑 id,可用于 /album/:source/:id' },
mid: { type: 'string', description: 'tx 平台专辑 mid' },
count: { type: ['integer', 'null'], description: '专辑曲目数' },
info: {
type: 'object',
properties: {
name: { type: 'string' },
author: { type: ['string', 'null'] },
img: { type: ['string', 'null'] },
desc: { type: ['string', 'null'] },
},
additionalProperties: true,
},
},
additionalProperties: true,
},
SingerAlbumListResult: {
type: 'object',
properties: {
source: { type: 'string' },
list: { type: 'array', items: ref('SingerAlbumItem') },
page: { type: 'integer' },
limit: { type: 'integer' },
total: { type: ['integer', 'string', 'null'] },
},
additionalProperties: true,
},
SingerSongListResult: {
type: 'object',
properties: {
source: { type: 'string' },
list: { type: 'array', items: ref('SongInfo') },
page: { type: 'integer' },
limit: { type: 'integer' },
total: { type: ['integer', 'string', 'null'] },
},
additionalProperties: true,
},
AlbumInfo: {
type: 'object',
properties: {
name: { type: ['string', 'null'], description: '专辑名' },
img: { type: ['string', 'null'], description: '专辑封面' },
desc: { type: ['string', 'null'], description: '专辑简介' },
author: { type: ['string', 'null'], description: '专辑作者' },
},
additionalProperties: true,
},
AlbumDetailResult: {
type: 'object',
properties: {
source: { type: 'string' },
info: ref('AlbumInfo'),
list: { type: 'array', items: ref('SongInfo'), description: '专辑曲目' },
page: { type: 'integer' },
limit: { type: ['integer', 'string', 'null'] },
total: { type: ['integer', 'string', 'null'] },
},
additionalProperties: true,
},
SongItem: {
type: 'object',
properties: {
@@ -179,6 +269,8 @@ export const COMPONENT_SCHEMAS = {
boards: { type: 'boolean' },
hotSearch: { type: 'boolean' },
songList: { type: 'boolean' },
singer: { type: 'boolean', description: '支持歌手接口(/singer/:source/:id)' },
album: { type: 'boolean', description: '支持专辑接口(/album/:source/:id)' },
},
additionalProperties: true,
},
+68
View File
@@ -285,6 +285,72 @@ export const musicRoutes = async(fastify) => {
return songListHandler(request.params.source, page, sortId, tagId)
})
// ---------- 歌手 & 专辑 ----------
fastify.get('/singer/:source/:id', {
schema: {
summary: '获取歌手信息',
description: '支持 kg/tx/wy(见 /sources 的 singer 能力位)。',
tags: ['歌手专辑'],
params: params({ source: pSource('singer'), id: { type: 'string', description: '平台歌手 id' } }),
response: responses('SingerInfoResult'),
},
}, async(request) => {
const { source, id } = request.params
return getSource(musicSdk, source, 'singer').singer
.getInfo(id).catch(err => { throw upstream(err.message) })
})
fastify.get('/singer/:source/:id/songs', {
schema: {
summary: '获取歌手歌曲列表',
description: '支持 kg/tx/wy。',
tags: ['歌手专辑'],
params: params({ source: pSource('singer'), id: { type: 'string', description: '平台歌手 id' } }),
querystring: qs({ page: pPage, limit: pLimit }),
response: responses('SingerSongListResult'),
},
}, async(request) => {
const { source, id } = request.params
const { page, limit } = request.query
return getSource(musicSdk, source, 'singer').singer
.getSongList(id, page, limit).catch(err => { throw upstream(err.message) })
})
fastify.get('/singer/:source/:id/albums', {
schema: {
summary: '获取歌手专辑列表',
description: '支持 kg/tx/wy。',
tags: ['歌手专辑'],
params: params({ source: pSource('singer'), id: { type: 'string', description: '平台歌手 id' } }),
querystring: qs({ page: pPage, limit: pLimit }),
response: responses('SingerAlbumListResult'),
},
}, async(request) => {
const { source, id } = request.params
const { page, limit } = request.query
return getSource(musicSdk, source, 'singer').singer
.getAlbumList(id, page, limit).catch(err => { throw upstream(err.message) })
})
fastify.get('/album/:source/:id', {
schema: {
summary: '获取专辑详情与曲目列表',
description: '支持 kg/mg(见 /sources 的 album 能力位)。kw 的专辑上游接口已失效暂不支持。',
tags: ['歌手专辑'],
params: params({ source: pSource('album'), id: { type: 'string', description: '平台专辑 id(搜索结果中的 albumId)' } }),
querystring: qs({ page: pPage, limit: pLimit }),
response: responses('AlbumDetailResult'),
},
}, async(request) => {
const { source, id } = request.params
const { page, limit } = request.query
const album = getSource(musicSdk, source, 'album').album
// kg/mg 为 getAlbumDetail(id, page, limit),其他平台适配器如只提供 getAlbumListDetail(id, page) 也可接入
const fn = album.getAlbumDetail || album.getAlbumListDetail
return fn.call(album, id, page, limit).catch(err => { throw upstream(err.message) })
})
// ---------- 系统 ----------
fastify.get('/health', {
@@ -327,6 +393,8 @@ export const musicRoutes = async(fastify) => {
boards: Boolean(m.leaderboard),
hotSearch: Boolean(m.hotSearch),
songList: Boolean(m.songList),
singer: Boolean(m.singer),
album: Boolean(m.album),
},
}
}),
+5
View File
@@ -10,6 +10,10 @@
// GET /boards/:source/:boardId 榜单详情
// GET /hot-search/:source 热搜词
// GET /song-list/:source 歌单列表
// GET /singer/:source/:id 歌手信息(kg/tx/wy)
// GET /singer/:source/:id/songs 歌手歌曲(kg/tx/wy)
// GET /singer/:source/:id/albums 歌手专辑(kg/tx/wy)
// GET /album/:source/:id 专辑详情+曲目(kg/kw/mg)
// GET /sources /health GET /docs /redoc /openapi.json
//
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
@@ -139,6 +143,7 @@ fastify.register(swagger, {
{ name: '歌曲资源', description: '播放地址、歌词、封面' },
{ name: '榜单', description: '排行榜' },
{ name: '歌单', description: '歌单与热搜' },
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
{ name: '系统', description: '健康检查、平台信息、文档' },
],
},
+52 -1
View File
@@ -2,7 +2,7 @@
// 运行:node src/smoke-test.js
//
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
// 跨平台匹配 /find、歌手/专辑、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
// 文档页、旧 query 路由移除回归
import http from 'node:http'
@@ -220,6 +220,57 @@ await test('歌单 默认排序 /song-list/kw', async() => {
return `${r.data.list.length} 个歌单`
})
// ---- 歌手 & 专辑 ----
await test('歌手信息 /singer/kg', async() => {
const r = await get('/singer/kg/3062')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无歌手名')
return r.data.info.name
})
await test('歌手信息 /singer/wy', async() => {
const r = await get('/singer/wy/3684')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无歌手名')
return r.data.info.name
})
await test('歌手信息 /singer/tx', async() => {
const r = await get('/singer/tx/0025NhlN2yWrP4')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无歌手名')
return r.data.info.name
})
await test('歌手歌曲 /singer/tx/:id/songs', async() => {
const r = await get('/singer/tx/0025NhlN2yWrP4/songs?page=1&limit=3')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 首 / 共 ${r.data.total}`
})
await test('歌手专辑 /singer/kg/:id/albums', async() => {
const r = await get('/singer/kg/3062/albums?page=1&limit=3')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 张`
})
await test('专辑详情 /album/kg', async() => {
const r = await get('/album/kg/171089481')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name || !r.data?.list) throw new Error('响应不完整')
return `${r.data.info.name}(${r.data.list.length} 首)`
})
await test('专辑详情 /album/mg', async() => {
const r = await get('/album/mg/25578')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.info?.name) throw new Error('无专辑名')
return `${r.data.info.name}(${r.data.list?.length ?? 0} 首)`
})
await test('参数校验 422(缺少 name)', async() => {
const r = await getRaw('/search')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)