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

273 lines
11 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/playlist.go + qq.go 的 fetchPlaylistDetail 移植)
// 搜索/分类复用现有 sdk/tx/songList.ts(handleParseId / getListId / getListDetail / getTag / getList)
// 推荐:musicu.fcg playlist.HotRecommendServer/get_hot_recommend(上游 GetRecommendedPlaylists)
// 链接解析:extractPlaylistId 为上游 extractQQPlaylistID 的 1:1 移植(含单测用例断言)
import { httpFetch } from '../request.ts'
import { formatPlayCount } from '../index.ts'
import songList from './songList.ts'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
// get_category_content 固定页大小(songList.limit_list = 36,运行时读取以避免模块环上的 TDZ)
const apiLimit = () => Number(songList.limit_list) || 36
const playlistLink = (id: string) => `https://y.qq.com/n/ryqq/playlist/${id}`
/** 歌单摘要:兼容既有 songList.filterList/filterList2 字段并补 spec 形状 */
const toSummary = (item: any) => {
const id = String(item?.id ?? '').trim()
if (!id) return null
const cover = String(item?.img ?? item?.cover ?? '')
const trackCount = Number(item?.total ?? item?.trackCount ?? 0) || 0
return {
...item,
id,
name: item?.name ?? '',
cover,
trackCount,
creator: item?.author ?? item?.creator ?? '',
description: item?.desc ?? item?.description ?? '',
link: playlistLink(id),
source: 'tx' as const,
}
}
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const result = await songList.search(String(keyword ?? ''), page, limit)
const list = (result?.list ?? []).map(toSummary).filter(Boolean)
return { list, page, limit, total: result?.total ?? list.length, source: 'tx' as const }
}
/** 歌单详情(上游 fetchPlaylistDetail;复用 songList.getListDetail,按 page/limit 切片) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const detail = await songList.getListDetail(String(id ?? ''))
const start = Math.max(0, (page - 1) * limit)
return {
source: 'tx' as const,
info: detail.info,
list: detail.list.slice(start, start + limit),
page,
limit,
total: detail.total ?? detail.list.length,
}
}
/** 歌单曲目全量(上游 GetPlaylistSongs) */
export const getPlaylistSongs = async(id: string) => {
const detail = await songList.getListDetail(String(id ?? ''))
return detail.list
}
/** 歌单分类(上游 GetPlaylistCategories;复用 songList.getTag / getHotTag) */
export const getCategories = async() => {
const [groups, hotTags] = await Promise.all([
songList.getTag().catch(() => []),
songList.getHotTag().catch(() => []),
])
const hotSet = new Set((hotTags ?? []).map((item: any) => String(item.id)))
const list: any[] = [{ id: '', name: '全部', group: '全部' }]
const seen = new Set<string>([''])
for (const group of groups ?? []) {
for (const item of group?.list ?? []) {
const id = String(item?.id ?? '').trim()
const name = String(item?.name ?? '').trim()
if (!id || !name || seen.has(id)) continue
seen.add(id)
list.push({ id, name, group: group.name ?? '', hot: hotSet.has(id) })
}
}
return { source: 'tx' as const, list }
}
/** 分类歌单(上游 GetCategoryPlaylists;get_category_content 每页 36 条,按入参 limit 重新切片)
* 直接解析原始响应:song_cnt/play_cnt(分类)与 song_ids/access_num(全部)在既有
* songList.filterList/filterList2 中未完整映射,这里按上游字段补齐后再切片(不改动既有 songList 行为)。
* categoryId 为空或 '10000000'(上游「全部」默认值)时走 get_playlist_by_tag 广场分支。 */
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const cat = String(categoryId ?? '').trim()
const isAll = cat === '' || cat === '10000000'
const start = Math.max(0, (page - 1) * limit)
const apiPages = Math.max(1, Math.ceil((start + limit) / apiLimit()))
const items: any[] = []
let total = 0
for (let apiPage = 1; apiPage <= apiPages; apiPage++) {
const url = isAll ? songList.getListUrl('5', '', apiPage) : songList.getListUrl('5', cat, apiPage)
const { statusCode, body } = await httpFetch(url, { timeout: 15000 }).promise
if (statusCode !== 200 || body?.code != 0) throw new Error(`tx 分类歌单获取失败 (code ${body?.code ?? statusCode})`)
const data = body?.playlist?.data ?? {}
if (isAll) {
total = Number(data.total ?? total) || total
const batch = (data.v_playlist ?? []).map((item: any) => {
const playCount = Number(item?.access_num ?? 0) || 0
return {
id: String(item?.tid ?? '').trim(),
name: String(item?.title ?? '').trim(),
cover: String(item?.cover_url_medium || item?.cover_url_big || ''),
creator: item?.creator_info?.nick ?? '',
trackCount: Array.isArray(item?.song_ids) ? item.song_ids.length : 0,
playCount,
play_count: formatPlayCount(playCount),
description: String(item?.desc ?? '').replace(/<br>/g, '\n'),
}
}).filter((item: any) => item.id && item.name)
items.push(...batch)
if (batch.length < apiLimit()) break
} else {
const content = data.content
total = Number(content?.total_cnt ?? total) || total
const batch = filterCategoryContent(content?.v_item)
items.push(...batch)
if (batch.length < apiLimit()) break
}
}
const list = items.slice(start, start + limit).map(toSummary).filter(Boolean)
return { list, page, limit, total: total || items.length, source: 'tx' as const }
}
/** 上游 filterList2 的字段映射 + song_cnt/play_cnt(trackCount/playCount 数字原值) */
const filterCategoryContent = (raw: any[]) =>
(raw ?? []).map(({ basic }) => {
const id = String(basic?.tid ?? '').trim()
const name = String(basic?.title ?? '').trim()
if (!id || !name) return null
const playCount = Number(basic?.play_cnt ?? 0) || 0
const cover = String(basic?.cover?.medium_url || basic?.cover?.default_url || '')
return {
id,
name,
img: cover,
cover,
author: basic?.creator?.nick ?? '',
creator: basic?.creator?.nick ?? '',
play_count: formatPlayCount(playCount),
playCount,
trackCount: Number(basic?.song_cnt ?? 0) || 0,
total: Number(basic?.song_cnt ?? 0) || 0,
desc: String(basic?.desc ?? '').replace(/<br>/g, '\n'),
description: String(basic?.desc ?? '').replace(/<br>/g, '\n'),
source: 'tx' as const,
}
}).filter(Boolean)
/** 推荐歌单(上游 GetRecommendedPlaylists:musicu.fcg get_hot_recommend) */
export const getRecommended = async(page = 1, limit = 20) => {
const { statusCode, body } = await httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
method: 'post',
headers: {
'User-Agent': UA,
Referer: 'https://y.qq.com/',
'Content-Type': 'application/json',
},
body: {
comm: { ct: 24 },
recomPlaylist: {
method: 'get_hot_recommend',
module: 'playlist.HotRecommendServer',
param: { async: 1, cmd: 2 },
},
},
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.code != 0) {
throw new Error(`tx 推荐歌单获取失败 (code ${body?.code ?? statusCode})`)
}
const vHot: any[] = body?.recomPlaylist?.data?.v_hot ?? []
const list = vHot.map((item: any) => {
const id = String(item?.content_id ?? item?.dissid ?? item?.id ?? item?.tid ?? '').trim()
const name = String(item?.title ?? item?.dissname ?? item?.name ?? '').trim()
if (!id || !name) return null
const cover = String(item?.cover ?? item?.imgurl ?? '')
const playCount = Number(item?.listen_num ?? item?.listennum ?? 0) || 0
return {
id,
name,
cover: cover.startsWith('http://') ? cover.replace('http://', 'https://') : cover,
trackCount: Number(item?.song_cnt ?? item?.song_count ?? item?.songnum ?? 0) || 0,
playCount,
play_count: formatPlayCount(playCount),
creator: String(item?.username ?? item?.creator?.nick ?? '').trim(),
description: '',
link: playlistLink(id),
source: 'tx' as const,
}
}).filter(Boolean)
if (!list.length) throw new Error('no recommended playlists found')
const start = Math.max(0, (page - 1) * limit)
return { list: list.slice(start, start + limit), page, limit, total: list.length, source: 'tx' as const }
}
export const getRecommendedPlaylists = getRecommended
/** 上游 extractQQPlaylistID:支持现代 ryqq 路由 / 老版详情页 id、disstid 查询;拒绝非歌单链接 */
export const extractPlaylistId = (link: string): string | null => {
const raw = String(link ?? '').trim()
if (!raw) return null
try {
const parsed = new URL(raw)
const pathValue = parsed.pathname.toLowerCase()
if (pathValue.includes('playlist')) {
for (const key of ['id', 'disstid', 'dissid']) {
const id = normalizePlaylistId(parsed.searchParams.get(key) ?? '')
if (id) return id
}
}
const parts = parsed.pathname.replace(/^\/+|\/+$/g, '').split('/')
for (let i = 0; i < parts.length; i++) {
if (parts[i].toLowerCase() === 'playlist' && i + 1 < parts.length) {
const id = normalizePlaylistId(parts[i + 1])
if (id) return id
}
}
} catch {
// 非 URL(裸 id)走下方兜底
}
const match = /(?:^|\/)playlist\/(\d+)(?:[/?#]|$)/.exec(raw)
if (match) return match[1]
return null
}
const normalizePlaylistId = (value: string): string => {
const trimmed = String(value ?? '').trim()
if (!trimmed || !/^\d+$/.test(trimmed)) return ''
return trimmed
}
/** 歌单链接解析(上游 ParsePlaylist):提取 disstid 后取详情;短链走 handleParseId 重定向兜底 */
export const parsePlaylistLink = async(link: string) => {
const raw = String(link ?? '').trim()
if (!raw) throw new Error('无效的QQ音乐歌单链接')
// 明确是歌曲/专辑页时直接拒绝,避免把其中的数字 id 误判为歌单(上游同样拒绝)
if (!/playlist/i.test(raw) && /(albumDetail|\/album\/|albummid=|songDetail|\/song\/|songmid=|[?&]songid=)/.test(raw)) {
throw new Error('无效的QQ音乐歌单链接')
}
let id = extractPlaylistId(raw)
if (!id && !/\d/.test(raw)) throw new Error('无效的QQ音乐歌单链接')
if (!id) {
// 纯数字裸 id 或短链:复用 songList 的 ID 归一(含 302 兜底)
try {
id = await songList.getListId(raw)
} catch {
id = ''
}
}
if (!id) throw new Error('无效的QQ音乐歌单链接')
return getPlaylistDetail(id)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
getRecommendedPlaylists,
parsePlaylistLink,
extractPlaylistId,
}