Files
MusicServer/sdk/tx/userPlaylist.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

233 lines
8.4 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.
// 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 }