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,232 @@
|
||||
// QQ音乐个人歌单(上游 music-lib/qq/user_playlist.go 移植)
|
||||
// 需登录 cookie(sdk/auth/store.ts 的 getCredentials('tx').cookie,须含 uin 类字段):
|
||||
// 未登录/缺 uin 抛出清晰错误;不静默返回空列表
|
||||
// 数据源(与上游一致,结果去重):
|
||||
// 1) fcg_user_created_diss → 自建歌单
|
||||
// 2) fcg_get_profile_order_asset reqtype=3 → 收藏歌单
|
||||
// 3) fcg_get_profile_order_asset reqtype=1 → 「我喜欢的歌曲」虚拟歌单摘要(有歌才附上)
|
||||
// 说明:虚拟条目的 id 为 profile:favorites(与上游一致),歌曲需凭 cookie 走同一接口拉取
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayCount } from '../index.ts'
|
||||
import { getCredentials } from '../auth/store.ts'
|
||||
import type { TxCredentials } from '../auth/store.ts'
|
||||
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
const FAVORITE_ID = 'profile:favorites'
|
||||
const FAVORITE_NAME = '我喜欢的歌曲'
|
||||
|
||||
/** 内联读取 cookie(与 sdk/auth/store.ts 的 TxCredentials 同源;避免动 store) */
|
||||
const readCookie = (): string => {
|
||||
const cred = getCredentials('tx') as TxCredentials | null
|
||||
return String(cred?.cookie ?? '').trim()
|
||||
}
|
||||
|
||||
/** 上游 normalizeQQUIN:从 cookie 中按已知键取 uin */
|
||||
const cookieValue = (cookie: string, key: string): string => {
|
||||
for (const part of cookie.split(';')) {
|
||||
const kv = part.trim().split(/=(.*)/s)
|
||||
if (kv.length === 2 && kv[0].trim() === key) return kv[1].trim()
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const normalizeQQUIN = (cookie: string): string => {
|
||||
const raw = [
|
||||
'wxuin', 'uin', 'ptui_loginuin', 'luin', 'pt2gguin', 'superuin', 'p_uin', 'musicid', 'userid',
|
||||
].map(key => cookieValue(cookie, key)).find(value => value) ?? ''
|
||||
return raw.replace(/^o/, '').replace(/^0+/, '').trim()
|
||||
}
|
||||
|
||||
const playlistLink = (id: string) => `https://y.qq.com/n/ryqq/playlist/${id}`
|
||||
|
||||
const normalizeCover = (cover: string): string => {
|
||||
const value = String(cover ?? '').trim()
|
||||
if (value.startsWith('//')) return `https:${value}`
|
||||
if (value.startsWith('http://')) return value.replace('http://', 'https://')
|
||||
return value
|
||||
}
|
||||
|
||||
type PlaylistSummary = {
|
||||
id: string
|
||||
name: string
|
||||
cover: string
|
||||
trackCount: number
|
||||
playCount: number
|
||||
play_count: string
|
||||
creator: string
|
||||
description: string
|
||||
link: string
|
||||
source: 'tx'
|
||||
}
|
||||
|
||||
const makeSummary = (partial: Partial<PlaylistSummary> & { id: string, name: string }): PlaylistSummary => {
|
||||
const playCount = Number(partial.playCount ?? 0) || 0
|
||||
return {
|
||||
id: partial.id,
|
||||
name: partial.name,
|
||||
cover: normalizeCover(partial.cover ?? ''),
|
||||
trackCount: Number(partial.trackCount ?? 0) || 0,
|
||||
playCount,
|
||||
play_count: formatPlayCount(playCount),
|
||||
creator: partial.creator ?? '',
|
||||
description: partial.description ?? '',
|
||||
link: partial.link ?? playlistLink(partial.id),
|
||||
source: 'tx',
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 profileOrderAssetParams */
|
||||
const profileAssetUrl = (uin: string, reqtype: string, page: number, limit: number) => {
|
||||
const offset = (Math.max(1, page) - 1) * limit
|
||||
const params = new URLSearchParams({
|
||||
format: 'json',
|
||||
inCharset: 'utf8',
|
||||
outCharset: 'utf-8',
|
||||
platform: 'yqq.json',
|
||||
needNewCode: '0',
|
||||
loginUin: uin,
|
||||
hostUin: '0',
|
||||
notice: '0',
|
||||
g_tk: '5381',
|
||||
ct: '20',
|
||||
cid: '205360956',
|
||||
userid: uin,
|
||||
reqtype,
|
||||
sin: String(offset),
|
||||
ein: String(offset + limit - 1),
|
||||
})
|
||||
return `https://c.y.qq.com/fav/fcgi-bin/fcg_get_profile_order_asset.fcg?${params.toString()}`
|
||||
}
|
||||
|
||||
const fetchJson = async(url: string, cookie: string) => {
|
||||
const { statusCode, body } = await httpFetch(url, {
|
||||
headers: { 'User-Agent': UA, Referer: 'https://y.qq.com/', Cookie: cookie },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error('tx 个人歌单接口请求失败')
|
||||
return body
|
||||
}
|
||||
|
||||
/** 自建歌单(上游 fcg_user_created_diss 段) */
|
||||
const fetchCreated = async(cookie: string, uin: string, page: number, limit: number) => {
|
||||
const params = new URLSearchParams({
|
||||
hostuin: uin,
|
||||
sin: String((page - 1) * limit),
|
||||
size: String(limit),
|
||||
format: 'json',
|
||||
inCharset: 'utf8',
|
||||
outCharset: 'utf-8',
|
||||
})
|
||||
const body = await fetchJson(`https://c.y.qq.com/rsc/fcgi-bin/fcg_user_created_diss?${params.toString()}`, cookie)
|
||||
if (body.code != 0) throw new Error(`tx 自建歌单获取失败: ${body.message ?? ''} (code ${body.code})`)
|
||||
const list: any[] = []
|
||||
for (const item of body?.data?.disslist ?? []) {
|
||||
const dissID = Number(item?.dissid) || 0
|
||||
const tid = Number(item?.tid) || 0
|
||||
const dirID = Number(item?.dirid) || 0
|
||||
const id = dissID > 0 ? String(dissID) : tid > 0 ? String(tid) : dirID > 0 ? `profile:dir:${dirID}` : ''
|
||||
const name = String(item?.diss_name ?? item?.title ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
list.push(makeSummary({
|
||||
id,
|
||||
name,
|
||||
cover: item?.diss_cover ?? item?.cover,
|
||||
trackCount: Number(item?.song_count ?? item?.song_num ?? item?.song_cnt ?? 0) || 0,
|
||||
playCount: Number(item?.listen_num ?? item?.visitnum ?? 0) || 0,
|
||||
creator: uin,
|
||||
description: String(item?.diss_desc ?? item?.desc ?? ''),
|
||||
}))
|
||||
}
|
||||
for (const item of body?.data?.list ?? []) {
|
||||
const id = String(item?.dissid ?? '').trim()
|
||||
const name = String(item?.dissname ?? '').trim()
|
||||
if (!id || !name) continue
|
||||
list.push(makeSummary({
|
||||
id,
|
||||
name,
|
||||
cover: item?.imgurl,
|
||||
trackCount: Number(item?.song_count ?? item?.song_num ?? 0) || 0,
|
||||
playCount: Number(item?.listennum ?? 0) || 0,
|
||||
creator: uin,
|
||||
description: String(item?.introduction ?? ''),
|
||||
}))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/** 收藏歌单(上游 fetchProfileOrderPlaylists,reqtype=3) */
|
||||
const fetchCollected = async(cookie: string, uin: string, page: number, limit: number) => {
|
||||
const body = await fetchJson(profileAssetUrl(uin, '3', page, limit), cookie)
|
||||
if (body.code != 0) return []
|
||||
const list: any[] = []
|
||||
for (const item of body?.data?.cdlist ?? []) {
|
||||
const id = Number(item?.dissid) || 0
|
||||
const name = String(item?.dissname ?? '').trim()
|
||||
if (id <= 0 || !name) continue
|
||||
list.push(makeSummary({
|
||||
id: String(id),
|
||||
name,
|
||||
cover: item?.logo,
|
||||
trackCount: Number(item?.songnum ?? 0) || 0,
|
||||
playCount: Number(item?.listennum ?? 0) || 0,
|
||||
creator: String(item?.nickname ?? item?.uin ?? uin),
|
||||
}))
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/** 「我喜欢的歌曲」摘要(上游 fetchFavoriteSongsPlaylistSummary,reqtype=1 limit=1) */
|
||||
const fetchFavoriteSummary = async(cookie: string, uin: string) => {
|
||||
const body = await fetchJson(profileAssetUrl(uin, '1', 1, 1), cookie)
|
||||
if (body.code != 0) return null
|
||||
const total = Number(body?.data?.totalsong ?? 0) || 0
|
||||
if (total <= 0) return null
|
||||
const first = body?.data?.songlist?.[0]?.data
|
||||
return makeSummary({
|
||||
id: FAVORITE_ID,
|
||||
name: FAVORITE_NAME,
|
||||
cover: first?.albummid ? `https://y.gtimg.cn/music/photo_new/T002R300x300M000${first.albummid}.jpg` : '',
|
||||
trackCount: total,
|
||||
creator: uin,
|
||||
description: 'QQ音乐我喜欢的歌曲',
|
||||
link: 'https://y.qq.com/n/ryqq/profile',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人歌单列表(上游 GetUserPlaylists)
|
||||
* 未登录(无 cookie 或无 uin)时抛出清晰错误,不返回空列表
|
||||
*/
|
||||
export const getList = async(page = 1, limit = 30) => {
|
||||
const cookie = readCookie()
|
||||
if (!cookie) throw new Error('tx 个人歌单需要登录 cookie(请先登录 QQ音乐)')
|
||||
const uin = normalizeQQUIN(cookie)
|
||||
if (!uin) throw new Error('tx 个人歌单需要 cookie 中的 uin(当前 cookie 不含 uin/wxuin 等字段)')
|
||||
|
||||
const safePage = Math.max(1, Number(page) || 1)
|
||||
let safeLimit = Number(limit) || 30
|
||||
if (safeLimit <= 0) safeLimit = 30
|
||||
if (safeLimit > 100) safeLimit = 100
|
||||
|
||||
const seen = new Set<string>()
|
||||
const list: PlaylistSummary[] = []
|
||||
const add = (summary: PlaylistSummary | null) => {
|
||||
if (!summary || !summary.id || !summary.name || seen.has(summary.id)) return
|
||||
seen.add(summary.id)
|
||||
list.push(summary)
|
||||
}
|
||||
|
||||
try {
|
||||
add(await fetchFavoriteSummary(cookie, uin))
|
||||
} catch {
|
||||
// 摘要失败不阻塞列表(与上游一致)
|
||||
}
|
||||
for (const item of await fetchCreated(cookie, uin, safePage, safeLimit)) add(item)
|
||||
for (const item of await fetchCollected(cookie, uin, safePage, safeLimit)) add(item)
|
||||
|
||||
return { list, page: safePage, limit: safeLimit, total: list.length, source: 'tx' as const }
|
||||
}
|
||||
|
||||
export const getUserPlaylists = getList
|
||||
|
||||
export default { getList, getUserPlaylists, normalizeQQUIN }
|
||||
Reference in new issue
Block a user