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:
1 parent
33b96f92c4
commit
68e071b166
20 files changed
+2218
-33
No files matched your search
@@ -0,0 +1,237 @@
|
||||
// 酷狗歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
|
||||
// 上游对应: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,
|
||||
}
|
||||
Reference in new issue
Block a user