Files
MusicServer/sdk/kw/album.ts
T
snowy-yang 68e071b166 feat(sdk): kg/kw/tx/wy 补齐专辑、歌单与 parse 能力
四个主流平台从「搜索 + 取流」补到能力对齐,新增 playlist/parse
(tx、wy 另加 userPlaylist),顺带修掉酷我直链的一个真实缺陷。

- kw:酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3…`),
  `$` 非 URL 标准分隔符,播放器与解析器普遍不认,导致拿到直链也放不出声;
  统一还原成 `=` 再对外返回
- wy:补齐 album/playlist/parse/userPlaylist,与 kg/tx 能力对齐
- kg/kw/tx:接入 parse(跨平台精确匹配的输入源)与歌单

为什么单独一次提交:这批改动不动架构,只做能力补齐与 URL 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
2026-10-03 00:26:51 +08:00

266 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { httpFetch } from '../request.ts'
import { decodeName } from '../index.ts'
import { formatSinger, objStr2JSON } from './util.ts'
import type { Quality } from '../types.ts'
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
// let requestObj_list
export default {
limit_list: 36,
limit_song: 1000,
filterListDetail(rawList, albumName, albumId) {
// console.log(rawList)
// console.log(rawList.length, rawList2.length)
return rawList.map((item, inedx) => {
let formats = item.formats.split('|')
let types: Array<{ type: Quality; size: string | null }> = []
let _types: Partial<Record<Quality, { size: string | null }>> = {}
if (formats.includes('MP3128')) {
types.push({ type: '128k', size: null })
_types['128k'] = {
size: null,
}
}
// if (formats.includes('MP3192')) {
// types.push({ type: '192k', size: null })
// _types['192k'] = {
// size: null,
// }
// }
if (formats.includes('MP3H')) {
types.push({ type: '320k', size: null })
_types['320k'] = {
size: null,
}
}
// if (formats.includes('AL')) {
// types.push({ type: 'ape', size: null })
// _types.ape = {
// size: null,
// }
// }
if (formats.includes('ALFLAC')) {
types.push({ type: 'flac', size: null })
_types.flac = {
size: null,
}
}
if (formats.includes('HIRFLAC')) {
types.push({ type: 'flac24bit', size: null })
_types.flac24bit = {
size: null,
}
}
// types.reverse()
return {
singer: formatSinger(decodeName(item.artist)),
name: decodeName(item.name),
albumName,
albumId,
songmid: item.id,
source: 'kw',
interval: null,
img: item.pic,
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {},
}
})
},
/**
* 格式化播放数量
* @param {*} num
*/
formatPlayCount(num: any) {
if (num > 100000000) return parseInt((num / 10000000) as any) / 10 + '亿'
if (num > 10000) return parseInt((num / 1000) as any) / 10 + '万'
return num
},
getAlbumListDetail(id, page, retryNum = 0) {
if (retryNum > 2) return Promise.reject(new Error('try max num'))
const requestObj_listDetail = httpFetch(`http://search.kuwo.cn/r.s?pn=${page - 1}&rn=${this.limit_song}&stype=albuminfo&albumid=${id}&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0`)
return requestObj_listDetail.promise.then(({ statusCode, body }) => {
if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
body = objStr2JSON(body)
// console.log(body)
if (!body.musiclist) return this.getAlbumListDetail(id, page, ++retryNum)
body.name = decodeName(body.name)
return {
list: this.filterListDetail(body.musiclist, body.name, body.albumid),
page,
limit: this.limit_song,
total: parseInt(body.songnum),
source: 'kw',
info: {
name: body.name,
img: body.img || body.hts_img,
desc: decodeName(body.info),
author: decodeName(body.artist),
// play_count: this.formatPlayCount(body.playnum),
},
}
})
},
/**
* 专辑详情(统一形状;服务端 1000 条/页,limit 更小时本地按 page/limit 开窗)
* 上游对应:kuwo.go fetchAlbumDetail(legacy API 部分)
*/
async getAlbumDetail(id: string, page = 1, limit = 1000) {
const albumID = String(id ?? '').trim()
if (!albumID) throw new Error('酷我专辑 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || this.limit_song)
if (pageSize >= this.limit_song) return this.getAlbumListDetail(albumID, pageNo)
const start = (pageNo - 1) * pageSize
let serverPage = Math.floor(start / this.limit_song) + 1
let skip = start % this.limit_song
const list: any[] = []
let info: any = null
let total = 0
while (list.length < pageSize && serverPage <= 50) {
const res = await this.getAlbumListDetail(albumID, serverPage)
if (!res) break
if (!info) info = res.info ?? null
total = Number(res.total ?? 0) || total
const rawList: any[] = res.list ?? []
if (!rawList.length) break
list.push(...rawList.slice(skip, skip + (pageSize - list.length)))
skip = 0
if (rawList.length < this.limit_song) break
if (total > 0 && list.length >= total) break
serverPage += 1
}
return {
source: 'kw',
info: info ?? { name: albumID, img: '', desc: '', author: '' },
list,
page: pageNo,
limit: pageSize,
total: total || list.length,
}
},
/** 上游 GetAlbumSongs:聚合全部分页曲目(去重) */
async getAlbumSongs(id: string) {
const albumID = String(id ?? '').trim()
if (!albumID) throw new Error('酷我专辑 id 为空')
const songs: any[] = []
const seen = new Set<string>()
for (let page = 1; page <= 50; page++) {
const res = await this.getAlbumListDetail(albumID, page)
const rawList: any[] = res?.list ?? []
for (const song of rawList) {
const key = String(song?.songmid ?? '')
if (key && seen.has(key)) continue
if (key) seen.add(key)
songs.push(song)
}
const total = Number(res?.total ?? 0) || 0
if (rawList.length < this.limit_song) break
if (total > 0 && songs.length >= total) break
}
return songs
},
/**
* 专辑搜索(上游 SearchAlbum,legacy r.s ft=album 路由;返回歌单/专辑统一摘要形状)
*/
async searchAlbum(keyword: string, page = 1, limit = 10) {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kw' as const, list: [] as any[], page, limit, total: 0 }
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 10)
const apiURL = 'http://search.kuwo.cn/r.s?' + [
`all=${encodeURIComponent(word)}`,
'ft=album',
'itemset=web_2013',
'client=kt',
'pcmp4=1',
'geo=c',
'vipver=1',
`pn=${pageNo - 1}`,
`rn=${pageSize}`,
'rformat=json',
'encoding=utf8',
].join('&')
const { statusCode, body } = await httpFetch(apiURL).promise
if (statusCode !== 200) throw new Error('酷我专辑搜索失败')
const data = typeof body === 'string' ? (() => { try { return objStr2JSON(body) } catch { return null } })() : body
if (!data?.albumlist) throw new Error('酷我专辑搜索失败')
const list = (data.albumlist as any[]).map(item => {
const albumID = firstNonEmpty(item.albumid, item.id)
if (!albumID) return null
return {
id: albumID,
name: decodeName(String(item.name ?? '').trim()),
cover: firstNonEmpty(item.hts_img, item.img),
trackCount: parseInt(String(item.musiccnt ?? ''), 10) || 0,
playCount: parseInt(String(item.PLAYCNT ?? item.playcnt ?? ''), 10) || 0,
creator: decodeName(firstNonEmpty(item.aartist, item.artist)),
description: decodeName(String(item.info ?? '').trim()),
link: `http://www.kuwo.cn/album_detail/${albumID}`,
source: 'kw' as const,
type: 'album',
company: decodeName(String(item.company ?? '').trim()),
publishTime: String(item.pub ?? '').trim(),
}
}).filter(Boolean)
return {
source: 'kw' as const,
list,
page: pageNo,
limit: pageSize,
total: parseInt(String(data.TOTAL ?? data.total ?? ''), 10) || list.length,
}
},
/** 上游 ParseAlbum:album_detail/<id>、album/<id>、albumid=<id> */
async parseAlbumLink(link: string) {
const raw = String(link ?? '')
const patterns = [/album_detail\/(\d+)/, /album\/(\d+)/, /albumid=(\d+)/]
for (const pattern of patterns) {
const match = raw.match(pattern)
if (match) return this.getAlbumDetail(match[1]!, 1)
}
throw new Error('无效的酷我专辑链接')
},
// getAlbumListDetail(id, page, retryNum = 0) {
// if (retryNum > 2) return Promise.reject(new Error('try max num'))
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => {
// return resp.promise.then(({ statusCode, body }) => {
// console.log(body)
// return Promise.reject(new Error('failed'))
// // if (statusCode !== 200) return this.getAlbumListDetail(id, page, ++retryNum)
// // const data = body.data
// // console.log(data)
// // if (!data.musicList) return this.getAlbumListDetail(id, page, ++retryNum)
// // return {
// // list: this.filterListDetail(data.musiclist),
// // page,
// // limit: this.limit_song,
// // total: data.total,
// // source: 'kw',
// // info: {
// // name: data.album,
// // img: data.pic,
// // desc: data.albuminfo,
// // author: data.artist,
// // play_count: this.formatPlayCount(data.playCnt),
// // },
// // }
// })
// })
// },
}