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
+308 -106

No files matched your search

+4
View File
@@ -31,6 +31,10 @@ pnpm test # 运行冒烟测试(自动启动/关闭本地服务器)
| `GET /boards/:source/:boardId` | 榜单详情 | | `GET /boards/:source/:boardId` | 榜单详情 |
| `GET /hot-search/:source` | 热搜列表 | | `GET /hot-search/:source` | 热搜列表 |
| `GET /song-list/:source?page=1` | 歌单列表 | | `GET /song-list/:source?page=1` | 歌单列表 |
| `GET /singer/:source/:id` | 歌手信息(kg/tx/wy) |
| `GET /singer/:source/:id/songs?page=1&limit=30` | 歌手歌曲列表(kg/tx/wy) |
| `GET /singer/:source/:id/albums?page=1&limit=10` | 歌手专辑列表(kg/tx/wy) |
| `GET /album/:source/:id?page=1` | 专辑详情与曲目(kg/mg) |
| `GET /sources` | 平台列表(音质与能力) | | `GET /sources` | 平台列表(音质与能力) |
| `GET /health` | 健康检查 | | `GET /health` | 健康检查 |
| `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) | | `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) |
+13 -2
View File
@@ -29,7 +29,7 @@
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/health` | 健康检查(status / uptime / 平台数) | | GET | `/health` | 健康检查(status / uptime / 平台数) |
| GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList) | | GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList/singer/album) |
| GET | `/` | 302 重定向到 `/docs` | | GET | `/` | 302 重定向到 `/docs` |
| GET | `/docs` | Swagger UI 文档页 | | GET | `/docs` | Swagger UI 文档页 |
| GET | `/redoc` | ReDoc 阅读版文档 | | GET | `/redoc` | ReDoc 阅读版文档 |
@@ -39,7 +39,7 @@
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台(排除 xm),失败的平台跳过 | | GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台,失败的平台跳过 |
| GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 | | GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 |
### 歌曲资源 ### 歌曲资源
@@ -64,6 +64,17 @@
| GET | `/hot-search/:source` | 热搜词 | | GET | `/hot-search/:source` | 热搜词 |
| GET | `/song-list/:source` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 | | GET | `/song-list/:source` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 |
### 歌手 & 专辑
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/singer/:source/:id` | 歌手信息(name/desc/avatar 与歌曲/专辑数量)。支持 kg/tx/wy |
| GET | `/singer/:source/:id/songs` | 歌手歌曲列表;可带 `page`/`limit`(默认 20)。支持 kg/tx/wy |
| GET | `/singer/:source/:id/albums` | 歌手专辑列表;可带 `page`/`limit`。支持 kg/tx/wy |
| GET | `/album/:source/:id` | 专辑详情与曲目列表;可带 `page`/`limit`(limit 仅 kg 生效)。支持 kg/mg;kw 专辑上游接口已失效暂不支持 |
歌手/专辑 id 来源:搜索结果条目包含 `albumId`;歌手 id 需从平台方获取(不在搜索结果中)。
## 响应示例 ## 响应示例
```jsonc ```jsonc
+4
View File
@@ -6,6 +6,8 @@ import pic from './pic.ts'
import lyric from './lyric.ts' import lyric from './lyric.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts'
import album from './album.ts'
// import tipSearch from './tipSearch.js' // import tipSearch from './tipSearch.js'
const kg = { const kg = {
@@ -15,6 +17,8 @@ const kg = {
musicSearch, musicSearch,
hotSearch, hotSearch,
comment, comment,
singer,
album,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return apis('kg').getMusicUrl!(songInfo, type) return apis('kg').getMusicUrl!(songInfo, type)
}, },
+1 -1
View File
@@ -76,7 +76,7 @@ export default {
info: { info: {
name: item.albumname, name: item.albumname,
author: item.singername, author: item.singername,
img: item.replaceAll('{size}', '480'), img: String(item.imgurl || item.img || '').replace('{size}', '480'),
desc: item.intro, desc: item.intro,
}, },
} }
+7 -9
View File
@@ -29,23 +29,21 @@ export default {
play_count: listInfo.play_count, play_count: listInfo.play_count,
}, },
} }
}, }, /**
/**
* 通过AlbumId获取专辑信息 * 通过AlbumId获取专辑信息
* @param {*} id * @param {*} id
* @param {*} page
*/ */
async getAlbumInfo(id) { async getAlbumInfo(id) {
const info = await createHttpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/resource/album/v2.0?albumId=${id}`) const info = await createHttpFetch(`https://app.c.nf.migu.cn/MIGUM3.0/resource/album/v2.0?albumId=${id}`)
if (!info) return Promise.reject(new Error('Get album info error.')) if (!info) return Promise.reject(new Error('Get album info error.'))
return { return {
name: info.title, name: info.title ?? null,
image: info.imgItems.length ? info.imgItems[0].img : null, image: info.imgItems?.length ? info.imgItems[0].img : null,
desc: info.summary, desc: info.summary ?? null,
author: info.singer, author: info.singer ?? null,
play_count: formatPlayCount(info.opNumItem.playNum), play_count: formatPlayCount(info.opNumItem?.playNum ?? 0),
total: info.totalCount, total: parseInt(info.totalCount) || null,
} }
}, },
} }
+2
View File
@@ -6,6 +6,7 @@ import pic from './pic.ts'
import lyric from './lyric.ts' import lyric from './lyric.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import album from './album.js'
// import tipSearch from './tipSearch.ts' // import tipSearch from './tipSearch.ts'
const mg = { const mg = {
@@ -15,6 +16,7 @@ const mg = {
leaderboard, leaderboard,
hotSearch, hotSearch,
comment, comment,
album,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return apis('mg').getMusicUrl!(songInfo, type) return apis('mg').getMusicUrl!(songInfo, type)
}, },
+2
View File
@@ -5,6 +5,7 @@ import musicSearch from './musicSearch.ts'
import { apis } from '../api-source.ts' import { apis } from '../api-source.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts'
// import tipSearch from './tipSearch.ts' // import tipSearch from './tipSearch.ts'
const tx = { const tx = {
@@ -14,6 +15,7 @@ const tx = {
musicSearch, musicSearch,
hotSearch, hotSearch,
comment, comment,
singer,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return apis('tx')!.getMusicUrl(songInfo, type) return apis('tx')!.getMusicUrl(songInfo, type)
+34 -76
View File
@@ -61,38 +61,30 @@ export const filterMusicInfoItem = item => {
/** /**
* 创建一个适用于TX的Http请求 * musicu.fcg 通用请求(comm 参数对齐榜单模块的可用配置)。
* @param {*} url * 注意:musichallSinger.GetSingerDetail 等歌手详情接口在该通道已停用(104403),
* @param {*} options * 歌手名/统计改由歌单与专辑接口合成。
* @param {*} retryNum
*/ */
const createMusicuFetch = async(data, options?, retryNum = 0) => { const createMusicuFetch = async(data, retryNum = 0) => {
if (retryNum > 2) throw new Error('try max num') if (retryNum > 2) throw new Error('try max num')
let result let result
try { try {
result = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { result = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'POST', method: 'post',
body: {
comm: {
cv: 4747474,
ct: 24,
format: 'json',
inCharset: 'utf-8',
outCharset: 'utf-8',
uin: 0,
},
...data,
},
headers: { headers: {
'User-Angent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
},
body: {
comm: { uin: 0, format: 'json', ct: 20, cv: 1859 },
...data,
}, },
}).promise }).promise
} catch (err) { } catch (err) {
console.log(err) console.log(err)
return createMusicuFetch(data, options, ++retryNum) return createMusicuFetch(data, ++retryNum)
} }
if (result.statusCode !== 200 || result.body.code != 0) return createMusicuFetch(data, options, ++retryNum) if (result.statusCode !== 200 || result.body.code != 0) return createMusicuFetch(data, ++retryNum)
return result.body return result.body
} }
@@ -103,59 +95,28 @@ export default {
* @param {*} id * @param {*} id
*/ */
getInfo(id) { getInfo(id) {
return createMusicuFetch({ return Promise.all([
req_1: { createMusicuFetch({ req: { module: 'musichall.song_list_server', method: 'GetSingerSongList', param: { singerMid: id, order: 1, begin: 0, num: 1 } } }),
module: 'music.musichallSinger.SingerInfoInter', createMusicuFetch({ req: { module: 'music.musichallAlbum.AlbumListServer', method: 'GetAlbumList', param: { singerMid: id, order: 0, begin: 0, num: 1, songNumTag: 0, singerID: 0 } } }),
method: 'GetSingerDetail', ]).then(([songBody, albumBody]) => {
param: { if (songBody.req.code != 0 || albumBody.req.code != 0) throw new Error('get singer info faild.')
singer_mid: [id], const songData = songBody.req.data
ex_singer: 1, const albumData = albumBody.req.data
wiki_singer: 1, // 详情接口停用:歌手名从歌曲条目反查,头像用平台的固定图片 URL 规则
group_singer: 0, const singers = songData.songList?.[0]?.songInfo?.singer ?? []
pic: 1, const self = singers.find(s => s.mid === id) ?? singers[0]
photos: 0,
},
},
req_2: {
module: 'music.musichallAlbum.AlbumListServer',
method: 'GetAlbumList',
param: {
singerMid: id,
order: 0,
begin: 0,
num: 1,
songNumTag: 0,
singerID: 0,
},
},
req_3: {
module: 'musichall.song_list_server',
method: 'GetSingerSongList',
param: {
singerMid: id,
order: 1,
begin: 0,
num: 1,
},
},
}).then(body => {
if (body.req_1.code != 0 || body.req_2 != 0 || body.req_3 != 0) throw new Error('get singer info faild.')
const info = body.req_1.data.singer_list[0]
const music = body.req_3.data
const album = body.req_3.data
return { return {
source: 'tx', source: 'tx',
id: info.basic_info.singer_mid, id,
info: { info: {
name: info.basic_info.name, name: self?.name ?? '',
desc: info.ex_info.desc, desc: null,
avatar: info.pic.pic, avatar: `https://y.gtimg.cn/music/photo_new/T001R500x500M000${id}.jpg`,
gender: info.ex_info.genre === 1 ? 'man' : 'woman', gender: null,
}, },
count: { count: {
music: music.totalNum, music: songData.totalNum,
album: album.total, album: albumData.total,
}, },
} }
}) })
@@ -167,7 +128,6 @@ export default {
* @param {*} limit * @param {*} limit
*/ */
getAlbumList(id, page = 1, limit = 10) { getAlbumList(id, page = 1, limit = 10) {
if (page === 1) page = 0
return createMusicuFetch({ return createMusicuFetch({
req: { req: {
module: 'music.musichallAlbum.AlbumListServer', module: 'music.musichallAlbum.AlbumListServer',
@@ -175,7 +135,7 @@ export default {
param: { param: {
singerMid: id, singerMid: id,
order: 0, order: 0,
begin: page * limit, begin: (page - 1) * limit,
num: limit, num: limit,
songNumTag: 0, songNumTag: 0,
singerID: 0, singerID: 0,
@@ -201,19 +161,18 @@ export default {
* @param {*} limit * @param {*} limit
*/ */
async getSongList(id, page = 1, limit = 100) { async getSongList(id, page = 1, limit = 100) {
if (page === 1) page = 0 const body = await createMusicuFetch({
return createMusicuFetch({
req: { req: {
module: 'musichall.song_list_server', module: 'musichall.song_list_server',
method: 'GetSingerSongList', method: 'GetSingerSongList',
param: { param: {
singerMid: id, singerMid: id,
order: 1, order: 1,
begin: page * limit, begin: (page - 1) * limit,
num: limit, num: limit,
}, },
}, },
}).then(body => { })
if (body.req.code != 0) throw new Error('get singer song list faild.') if (body.req.code != 0) throw new Error('get singer song list faild.')
const list = this.filterSongList(body.req.data.songList) const list = this.filterSongList(body.req.data.songList)
@@ -224,7 +183,6 @@ export default {
page, page,
total: body.req.data.totalNum, total: body.req.data.totalNum,
} }
})
}, },
filterAlbumList(raw) { filterAlbumList(raw) {
return raw.map(item => { return raw.map(item => {
@@ -242,7 +200,7 @@ export default {
}) })
}, },
filterSongList(raw) { filterSongList(raw) {
raw.map(item => { return raw.map(item => {
return filterMusicInfoItem(item.songInfo) return filterMusicInfoItem(item.songInfo)
}) })
}, },
+2
View File
@@ -6,6 +6,7 @@ import musicSearch from './musicSearch.ts'
import songList from './songList.ts' import songList from './songList.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts'
// import tipSearch from './tipSearch.js' // import tipSearch from './tipSearch.js'
const wy = { const wy = {
@@ -15,6 +16,7 @@ const wy = {
songList, songList,
hotSearch, hotSearch,
comment, comment,
singer,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return apis('wy').getMusicUrl!(songInfo, type) return apis('wy').getMusicUrl!(songInfo, type)
}, },
+22 -17
View File
@@ -8,20 +8,23 @@ export default {
* @param {*} id * @param {*} id
*/ */
getInfo(id) { getInfo(id) {
return (eapiRequest('/api/artist/head/info/get', { id }) as any).then(({ body }) => { return eapiRequest('/api/artist/head/info/get', { id }).promise.then(({ body }) => {
if (!body || body.code != 200) throw new Error('get singer info faild.') if (!body || body.code != 200) throw new Error('get singer info faild.')
// 响应结构为 { code, data: { artist: { ... } } },无 body.user
const artist = body.data?.artist
if (!artist) throw new Error('get singer info faild.')
return { return {
source: 'wy', source: 'wy',
id: body.artist.id, id: artist.id,
info: { info: {
name: body.artist.name, name: artist.name,
desc: body.artist.briefDesc, desc: artist.briefDesc,
avatar: body.user.avatarUrl, avatar: artist.avatar ?? artist.cover ?? null,
gender: body.user.gender === 1 ? 'man' : 'woman', gender: null,
}, },
count: { count: {
music: body.artist.musicSize, music: artist.musicSize,
album: body.artist.albumSize, album: artist.albumSize,
}, },
} }
}) })
@@ -33,12 +36,11 @@ export default {
* @param {*} limit * @param {*} limit
*/ */
getSongList(id, page = 1, limit = 100) { getSongList(id, page = 1, limit = 100) {
if (page === 1) page = 0 return eapiRequest('/api/v2/artist/songs', {
return (eapiRequest('/api/v2/artist/songs', {
id, id,
limit, limit,
offset: limit * page, offset: (page - 1) * limit,
}) as any).then(({ body }) => { }).promise.then(({ body }) => {
if (!body.songs || body.code != 200) throw new Error('get singer song list faild.') if (!body.songs || body.code != 200) throw new Error('get singer song list faild.')
const list = this.filterSongList(body.songs) const list = this.filterSongList(body.songs)
@@ -58,11 +60,10 @@ export default {
* @param {*} limit * @param {*} limit
*/ */
getAlbumList(id, page = 1, limit = 10) { getAlbumList(id, page = 1, limit = 10) {
if (page === 1) page = 0 return eapiRequest(`/api/artist/albums/${id}`, {
return (eapiRequest(`/api/artist/albums/${id}`, {
limit, limit,
offset: limit * page, offset: (page - 1) * limit,
}) as any).then(({ body }) => { }).promise.then(({ body }) => {
if (!body.hotAlbums || body.code != 200) throw new Error('get singer album list faild.') if (!body.hotAlbums || body.code != 200) throw new Error('get singer album list faild.')
const list = this.filterAlbumList(body.hotAlbums) const list = this.filterAlbumList(body.hotAlbums)
@@ -108,24 +109,28 @@ export default {
_types['128k'] = { _types['128k'] = {
size, size,
} }
break
case 320000: case 320000:
size = item.hMusic ? sizeFormate(item.hMusic.size) : null size = item.hMusic ? sizeFormate(item.hMusic.size) : null
types.push({ type: '320k', size }) types.push({ type: '320k', size })
_types['320k'] = { _types['320k'] = {
size, size,
} }
break
case 999000: case 999000:
size = item.sqMusic ? sizeFormate(item.sqMusic.size) : null size = item.sqMusic ? sizeFormate(item.sqMusic.size) : null
types.push({ type: 'flac', size }) types.push({ type: 'flac', size })
_types.flac = { _types.flac = {
size, size,
} }
break
case 1999000: case 1999000:
size = item.hrMusic ? sizeFormate(item.hrMusic.size) : null size = item.hrMusic ? sizeFormate(item.hrMusic.size) : null
types.push({ type: 'flac24bit', size }) types.push({ type: 'flac24bit', size })
_types.flac24bit = { _types.flac24bit = {
size, size,
} }
break
} }
}) })
@@ -136,7 +141,7 @@ export default {
albumId: item.album.id, albumId: item.album.id,
songmid: item.id, songmid: item.id,
source: 'wy', source: 'wy',
interval: formatPlayTime(item.duration), interval: formatPlayTime((item.dt ?? item.duration) / 1000),
img: null, img: null,
lrc: null, lrc: null,
otherSource: null, otherSource: null,
+92
View File
@@ -102,6 +102,96 @@ export const COMPONENT_SCHEMAS = {
properties: { list: { type: 'array', items: ref('BoardItem') } }, properties: { list: { type: 'array', items: ref('BoardItem') } },
additionalProperties: true, 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: { SongItem: {
type: 'object', type: 'object',
properties: { properties: {
@@ -179,6 +269,8 @@ export const COMPONENT_SCHEMAS = {
boards: { type: 'boolean' }, boards: { type: 'boolean' },
hotSearch: { type: 'boolean' }, hotSearch: { type: 'boolean' },
songList: { type: 'boolean' }, songList: { type: 'boolean' },
singer: { type: 'boolean', description: '支持歌手接口(/singer/:source/:id)' },
album: { type: 'boolean', description: '支持专辑接口(/album/:source/:id)' },
}, },
additionalProperties: true, additionalProperties: true,
}, },
+68
View File
@@ -285,6 +285,72 @@ export const musicRoutes = async(fastify) => {
return songListHandler(request.params.source, page, sortId, tagId) 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', { fastify.get('/health', {
@@ -327,6 +393,8 @@ export const musicRoutes = async(fastify) => {
boards: Boolean(m.leaderboard), boards: Boolean(m.leaderboard),
hotSearch: Boolean(m.hotSearch), hotSearch: Boolean(m.hotSearch),
songList: Boolean(m.songList), songList: Boolean(m.songList),
singer: Boolean(m.singer),
album: Boolean(m.album),
}, },
} }
}), }),
+5
View File
@@ -10,6 +10,10 @@
// GET /boards/:source/:boardId 榜单详情 // GET /boards/:source/:boardId 榜单详情
// GET /hot-search/:source 热搜词 // GET /hot-search/:source 热搜词
// GET /song-list/: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 // GET /sources /health GET /docs /redoc /openapi.json
// //
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
@@ -139,6 +143,7 @@ fastify.register(swagger, {
{ name: '歌曲资源', description: '播放地址、歌词、封面' }, { name: '歌曲资源', description: '播放地址、歌词、封面' },
{ name: '榜单', description: '排行榜' }, { name: '榜单', description: '排行榜' },
{ name: '歌单', description: '歌单与热搜' }, { name: '歌单', description: '歌单与热搜' },
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
{ name: '系统', description: '健康检查、平台信息、文档' }, { name: '系统', description: '健康检查、平台信息、文档' },
], ],
}, },
+52 -1
View File
@@ -2,7 +2,7 @@
// 运行:node src/smoke-test.js // 运行:node src/smoke-test.js
// //
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、 // 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、 // 跨平台匹配 /find、歌手/专辑、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
// 文档页、旧 query 路由移除回归 // 文档页、旧 query 路由移除回归
import http from 'node:http' import http from 'node:http'
@@ -220,6 +220,57 @@ await test('歌单 默认排序 /song-list/kw', async() => {
return `${r.data.list.length} 个歌单` 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() => { await test('参数校验 422(缺少 name)', async() => {
const r = await getRaw('/search') const r = await getRaw('/search')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)