Files
MusicServer/sdk/kg/playlist.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

238 lines
8.7 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.
// 酷狗歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
// 上游对应:music-lib/kugou/playlist.go + kugou.go 的 fetchPlaylistDetail
// 链接解析优先复用既有 sdk/kg/songList.ts 的 getUserListDetail / getListDetailBySpecialId /
// getListInfoByChain / decodeGcid 等能力(覆盖 special/single、songlist/gcid、share chain、
// zlist、global_collection_id 等形态),本模块只做统一形状归一。
import { httpFetch } from '../request.ts'
import songList from './songList.ts'
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
const playlistCover = (url: any): string => String(url ?? '').replace('{size}', '240')
/** 歌单摘要(对齐契约形状;id 用平台 specialid,link 指向网页版) */
const toSummary = (item: any) => {
const rawId = firstNonEmpty(item?.specialid, item?.global_specialid, item?.id)
const id = rawId.replace(/^id_/, '')
if (!id) return null
const playCount = Number(item?.playcount)
return {
id,
name: firstNonEmpty(item?.specialname, item?.name),
cover: playlistCover(firstNonEmpty(item?.imgurl, item?.img)),
trackCount: Number(item?.songcount ?? item?.total ?? 0) || 0,
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
creator: firstNonEmpty(item?.nickname, item?.username, item?.author),
description: firstNonEmpty(item?.intro, item?.desc),
publishTime: String(item?.publishtime ?? item?.time ?? '').trim(),
link: `https://www.kugou.com/yy/special/single/${id}.html`,
source: 'kg' as const,
}
}
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 msearchretry v3 接口) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kg' as const, list: [] as any[], page, limit, total: 0 }
const res = await songList.search(word, page, limit)
const list = (res?.list ?? []).map(toSummary).filter(Boolean)
return {
source: 'kg' as const,
list,
page,
limit,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 数字 id / id_ 前缀 / 链接 → songList.getListDetail 可识别的入参 */
const normalizePlaylistId = (id: any): string => {
const raw = String(id ?? '').trim()
if (!raw) return ''
if (/^\d+$/.test(raw)) return `id_${raw}`
return raw
}
/**
* 歌单详情(含元数据与曲目)
* 委托 songList.getListDetail(special 页 / gcid songlist / share chain / 裸酷狗码等),
* 平台自身返回全量时按 page/limit 本地开窗。
*/
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const requestId = normalizePlaylistId(id)
if (!requestId) throw new Error('酷狗歌单 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const res = await songList.getListDetail(requestId, pageNo)
if (!res) throw new Error('酷狗歌单不存在或解析失败')
let list: any[] = res.list ?? []
const total = Number(res.total ?? 0) || list.length
if (list.length > pageSize) {
const start = (pageNo - 1) * pageSize
list = list.slice(start, start + pageSize)
}
return {
source: 'kg' as const,
info: {
name: firstNonEmpty(res.info?.name, String(id)),
img: playlistCover(res.info?.img),
desc: firstNonEmpty(res.info?.desc),
author: firstNonEmpty(res.info?.author),
play_count: res.info?.play_count ?? '',
},
list,
page: pageNo,
limit: pageSize,
total,
}
}
/** 上游 GetPlaylistSongs:只取曲目(复用详情全量) */
export const getPlaylistSongs = async(id: string) => {
const res = await songList.getListDetail(normalizePlaylistId(id), 1)
return res?.list ?? []
}
/** 歌单分类(上游 GetPlaylistCategories:tag/list;categoryId 形如 `<id>:<tagId>`) */
export const getCategories = async() => {
const { statusCode, body } = await httpFetch('http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0', {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗歌单分类获取失败')
}
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
{ id: '', name: '全部', group: '全部' },
]
for (const group of body?.data?.info ?? []) {
const groupName = String(group?.name ?? '').trim()
const groupID = Number(group?.id ?? 0) || 0
if (groupID > 0 && groupName) {
list.push({ id: `${groupID}:0`, name: groupName, group: groupName })
}
for (const child of group?.children ?? []) {
const id = Number(child?.id ?? 0) || 0
const name = String(child?.name ?? '').trim()
if (!id || !name) continue
const tagId = Number(child?.special_tag_id ?? 0) || 0
list.push({ id: `${id}:${tagId}`, name, group: groupName, hot: Number(child?.is_hot ?? 0) === 1 })
}
}
return { source: 'kg' as const, list }
}
/** categoryId → [id, tagId];空/dirty 时取分类表首个非空分类 */
const parseCategoryID = (categoryId: any): { id: string, tagId: string } => {
const parts = String(categoryId ?? '').trim().split(':')
const id = String(parts[0] ?? '').trim()
const tagId = String(parts[1] ?? '').trim() || '0'
return { id, tagId }
}
/** 分类歌单(上游 GetCategoryPlaylists:tag/specialList) */
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
let { id, tagId } = parseCategoryID(categoryId)
if (!id) {
const { list } = await getCategories()
const first = list.find(item => item.id)
if (!first) throw new Error('酷狗歌单分类为空')
;({ id, tagId } = parseCategoryID(first.id))
}
const apiURL = 'http://mobilecdnbj.kugou.com/api/v3/tag/specialList?' + [
'plat=0',
`page=${pageNo}`,
`tagid=${tagId}`,
`pagesize=${pageSize}`,
'ugc=1',
`id=${id}`,
'sort=2',
].join('&')
const { statusCode, body } = await httpFetch(apiURL, {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗分类歌单获取失败')
}
const list = (body?.data?.info ?? []).map((item: any) => {
const summary: any = toSummary(item) ?? toSummary({ ...item, specialid: item?.global_specialid })
if (!summary) return null
summary.categoryId = `${id}:${tagId}`
return summary
}).filter(Boolean)
return {
source: 'kg' as const,
list,
page: pageNo,
limit: pageSize,
total: Number(body?.data?.total ?? 0) || list.length,
}
}
/** 推荐歌单(上游 GetRecommendedPlaylists:m.kugou.com/plist/index&json=true) */
export const getRecommended = async(page = 1, limit = 30) => {
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 30)
const { statusCode, body } = await httpFetch(`http://m.kugou.com/plist/index&json=true&page=${pageNo}`, {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || typeof body !== 'object' || body == null) {
throw new Error('酷狗推荐歌单获取失败')
}
let list = (body?.plist?.list?.info ?? []).map(toSummary).filter(Boolean)
if (list.length > pageSize) list = list.slice(0, pageSize)
return {
source: 'kg' as const,
list,
page: pageNo,
limit: pageSize,
total: Number(body?.plist?.list?.total ?? 0) || list.length,
}
}
/**
* 上游 ParsePlaylist:
* /yy/special/single/<id>.html → special 详情
* /songlist/<gcid_xxx>/ → gcid 详情
* 其余链接形态(share chain、zlist、global_collection_id、裸 id/酷狗码)交给 songList 通用解析。
*/
export const parsePlaylistLink = async(link: string) => {
const raw = String(link ?? '').trim()
if (!raw) throw new Error('无效的酷狗歌单链接')
const special = raw.match(/special\/single\/(\d+)\.html/)
const gcid = raw.match(/songlist\/(gcid_[A-Za-z0-9]+)/)
if (special) return getPlaylistDetail(special[1]!, 1)
if (gcid) return getPlaylistDetail(gcid[1]!, 1)
return getPlaylistDetail(raw, 1)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
parsePlaylistLink,
}