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 规范化,
与新增平台的注册表改动分开,出问题时便于单独回滚。
This commit is contained in:
snowy-yang committed 2026-10-03 00:26:51 +08:00
1 parent 33b96f92c4
commit 68e071b166
20 files changed
+2218 -33

No files matched your search

+133
View File
@@ -3,6 +3,15 @@ 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,
@@ -102,6 +111,130 @@ export default {
}
})
},
/**
* 专辑详情(统一形状;服务端 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) => {