Files
MusicServer/sdk/tx/userPlaylist.ts
snowy-yang 8c02fb5c15 refactor(sdk): 审计 P2 卫生项清理——请求层收敛与死代码清除
- 删除旧 sdk/request.ts,全部 import 收敛到 sdk/common/request.ts(85 文件)
- 注释死代码 630+ 行、裸 console.log 吞错 6 处、未使用符号 26 处清零
- bili 状态校验去重;utils DNS 预解析失败日志改 warn 带上下文
- CODE-AUDIT.md:第四节 P2 清理记录 + 第八节线索逐条复核结论(kw/kg/tx/wy parse 骨架相似实质各异,不抽公共层)
2026-10-03 20:48:54 +08:00

233 lines
8.4 KiB
TypeScript
Raw Permalink 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 '../common/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 }