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

+95
View File
@@ -0,0 +1,95 @@
// 网易云个人歌单(上游 music-lib/netease/user_playlist.go 移植)
// 需登录 cookie MUSIC_U(sdk/auth/store.ts 的 getCredentials('wy').cookie / musicU):
// 未登录抛出清晰错误,不返回空列表
// 1) weapi/nuser/account/get → 当前用户 userId
// 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
import { httpFetch } from '../request.ts'
import { weapi } from './utils/crypto.ts'
import { formatPlayCount } from '../index.ts'
import { getCredentials, getWyCookie } from '../auth/store.ts'
import type { WyCredentials } from '../auth/store.ts'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
const readCookie = (): string => {
const cred = getCredentials('wy') as WyCredentials | null
const musicU = String(cred?.musicU ?? '').trim()
if (musicU) return `MUSIC_U=${musicU}`
return String(cred?.cookie ?? getWyCookie()).trim()
}
const weapiPost = async(url: string, data: object, cookie: string) => {
const { statusCode, body } = await httpFetch(url, {
method: 'post',
headers: {
'User-Agent': UA,
Referer: 'https://music.163.com/',
origin: 'https://music.163.com',
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: cookie,
},
form: weapi(data),
timeout: 15000,
}).promise
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error('wy 个人歌单接口请求失败')
return body
}
/**
* 个人歌单列表(上游 GetUserPlaylists)
* 未登录(无 cookie/MUSIC_U)时抛出清晰错误,不返回空列表
*/
export const getList = async(page = 1, limit = 30) => {
const cookie = readCookie()
if (!cookie) throw new Error('wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云')
const safePage = Math.max(1, Number(page) || 1)
let safeLimit = Number(limit) || 30
if (safeLimit <= 0) safeLimit = 30
if (safeLimit > 100) safeLimit = 100
const account = await weapiPost('https://music.163.com/weapi/nuser/account/get', { csrf_token: '' }, cookie)
if (account.code !== 200 || !account?.profile?.userId) {
throw new Error(`wy 登录态无效或已过期 (code ${account?.code ?? 'unknown'})`)
}
const uid = account.profile.userId
const nickname = account.profile.nickname ?? ''
const body = await weapiPost('https://music.163.com/weapi/user/playlist', {
uid,
limit: safeLimit,
offset: (safePage - 1) * safeLimit,
includeVideo: true,
csrf_token: '',
}, cookie)
if (body.code !== 200) throw new Error(`wy 个人歌单获取失败 (code ${body.code})`)
const list = (body.playlist ?? []).map(item => {
const id = String(item?.id ?? '').trim()
if (!id) return null
const trackCount = Number(item?.trackCount ?? 0) || 0
const playCount = Number(item?.playCount ?? 0) || 0
const creator = String(item?.creator?.nickname ?? '').trim() || nickname
return {
id,
name: item?.name ?? '',
cover: item?.coverImgUrl ?? '',
trackCount,
playCount,
play_count: formatPlayCount(playCount),
creator,
description: item?.description ?? '',
subscribed: Boolean(item?.subscribed),
link: playlistLink(id),
source: 'wy' as const,
}
}).filter(Boolean)
return { list, page: safePage, limit: safeLimit, total: list.length, source: 'wy' as const }
}
export const getUserPlaylists = getList
export default { getList, getUserPlaylists }