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

+217
View File
@@ -0,0 +1,217 @@
// 酷我歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
// 上游对应:music-lib/kuwo/playlist.go
// 复用既有内部实现(不重复造轮子):
// - 搜索:songList.search
// - 详情:songList.getListDetail(nplserver,服务端固定 1000 条/页,本地按 page/limit 开窗)
// - 分类:songList.getTag / getHotTag(同上游 getTagList / getRcmTagList)
// - 分类歌单 / 推荐:songList.getList(wapi getTagPlayList / getRcmPlayList)
// 上游 GetRecommendedPlaylists 用 getRcmPlayList(order=hot) 且 limit=30;既有 getList 的
// limit_list=36(模块常量),故推荐条数以 36 为准,显式 limit 更小时本地截断。
import songList from './songList.ts'
// songList.getListDetailUrl 固定 rn=limit_song=1000
const SERVER_PAGE_SIZE = 1000
const MAX_SERVER_PAGES = 60
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
/** 歌单链接 / digest 前缀 id → 平台内部 id(digest 前缀形态原样保留,交给 songList 分流) */
const normalizePlaylistId = (id: any): string => {
const raw = String(id ?? '').trim()
if (!raw) return ''
const patterns = [
/playlist_detail\/(\d+)/, // http://www.kuwo.cn/playlist_detail/2886046289
/\/playlist\/(\d+)/, // https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
/playlistId=(\d+)/,
]
for (const pattern of patterns) {
const match = raw.match(pattern)
if (match) return match[1]!
}
return raw
}
/** 歌单摘要(对齐契约形状:id/name/cover/trackCount/playCount/creator/description/link/source) */
const toSummary = (item: any) => {
const id = String(item?.id ?? '').trim()
const plainId = id.replace(/^digest-\w+__/, '')
const playCount = Number(item?.play_count)
return {
...item,
id,
name: String(item?.name ?? '').trim(),
cover: firstNonEmpty(item?.img, item?.cover),
trackCount: Number(item?.total ?? item?.trackCount ?? 0) || 0,
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
creator: firstNonEmpty(item?.author, item?.creator, item?.uname),
description: firstNonEmpty(item?.desc, item?.description),
link: plainId ? `http://www.kuwo.cn/playlist_detail/${plainId}` : '',
source: 'kw' as const,
}
}
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 legacy r.s 接口) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kw' as const, list: [] as any[], page, limit, total: 0 }
const res = await songList.search(word, page, limit)
const list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
return {
source: 'kw' as const,
list,
page,
limit,
total: Number(res?.total ?? 0) || list.length,
}
}
/**
* 歌单详情(含元数据与曲目)
* 服务端 1000 条/页,按调用方 page/limit 开窗,只抓所需的 1~2 个服务端页。
*/
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const playlistId = normalizePlaylistId(id)
if (!playlistId) throw new Error('酷我歌单 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const start = (pageNo - 1) * pageSize
let serverPage = Math.floor(start / SERVER_PAGE_SIZE) + 1
let skip = start % SERVER_PAGE_SIZE
const list: any[] = []
let info: any = null
let total = 0
while (list.length < pageSize && serverPage <= MAX_SERVER_PAGES) {
const res = await songList.getListDetail(playlistId, 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 < SERVER_PAGE_SIZE) break
if (total > 0 && serverPage * SERVER_PAGE_SIZE >= total) break
serverPage += 1
}
return {
source: 'kw' as const,
info: {
name: firstNonEmpty(info?.name, playlistId),
img: firstNonEmpty(info?.img),
desc: firstNonEmpty(info?.desc),
author: firstNonEmpty(info?.author),
play_count: info?.play_count ?? '',
},
list,
page: pageNo,
limit: pageSize,
total: total || list.length,
}
}
/** 上游 GetPlaylistSongs:聚合全部页曲目(去重) */
export const getPlaylistSongs = async(id: string) => {
const playlistId = normalizePlaylistId(id)
if (!playlistId) throw new Error('酷我歌单 id 为空')
const songListResult: any[] = []
const seen = new Set<string>()
for (let serverPage = 1; serverPage <= MAX_SERVER_PAGES; serverPage++) {
const res = await songList.getListDetail(playlistId, serverPage)
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)
songListResult.push(song)
}
const total = Number(res?.total ?? 0) || 0
if (rawList.length < SERVER_PAGE_SIZE) break
if (total > 0 && songListResult.length >= total) break
}
return songListResult
}
/** 歌单分类(上游 GetPlaylistCategories;复用 getTag/getHotTag,tagId 形如 `<id>-<digest>`) */
export const getCategories = async() => {
const { tags, hotTag } = await songList.getTags()
const hotIds = new Set((hotTag ?? []).map((item: any) => String(item?.id ?? '')))
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
{ id: '', name: '全部', group: '全部' },
]
for (const group of tags ?? []) {
for (const item of group?.list ?? []) {
const id = String(item?.id ?? '').trim()
const name = String(item?.name ?? '').trim()
if (!id || !name) continue
list.push({ id, name, group: String(group?.name ?? '').trim() || '其他', hot: hotIds.has(id) })
}
}
return { source: 'kw' as const, list }
}
/**
* 分类歌单(上游 GetCategoryPlaylists)
* categoryId 为空 → getRcmPlayList(order=hot);否则按 `<id>-<digest>` 走 getTagPlayList。
*/
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const tagId = String(categoryId ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const res = await songList.getList('hot', tagId || undefined, pageNo)
let list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
const requested = Number(limit) || 0
const serverLimit = Number(res?.limit ?? 0) || 0
if (requested > 0 && list.length > requested) list = list.slice(0, requested)
return {
source: 'kw' as const,
list,
page: pageNo,
limit: (requested > 0 && serverLimit > 0 ? Math.min(requested, serverLimit) : serverLimit) || list.length,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 推荐歌单(上游 GetRecommendedPlaylists;复用 getList(order=hot)) */
export const getRecommended = async(page = 1, limit = 30) => {
const pageNo = Math.max(1, Number(page) || 1)
const res = await songList.getList('hot', undefined, pageNo)
let list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
const requested = Number(limit) || 0
const serverLimit = Number(res?.limit ?? 0) || 0
if (requested > 0 && list.length > requested) list = list.slice(0, requested)
return {
source: 'kw' as const,
list,
page: pageNo,
limit: (requested > 0 && serverLimit > 0 ? Math.min(requested, serverLimit) : serverLimit) || list.length,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 上游 ParsePlaylist:playlist_detail/<id>、m.kuwo.cn/h5app/playlist/<id>(含 ?/# 尾串) */
export const parsePlaylistLink = async(link: string) => {
const match = String(link ?? '').match(/playlist_detail\/(\d+)/) ||
String(link ?? '').match(/\/playlist\/(\d+)/) ||
String(link ?? '').match(/playlistId=(\d+)/)
if (!match) throw new Error('无效的酷我歌单链接')
return getPlaylistDetail(match[1]!, 1)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
parsePlaylistLink,
}