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
+133
-31
@@ -1,45 +1,147 @@
|
||||
// QQ音乐专辑(上游 music-lib/qq/album.go + qq.go 的 fetchAlbumDetail 移植)
|
||||
// 搜索:c.y.qq.com soso search_for_qq_cp t=8(上游 SearchAlbum)
|
||||
// 曲目:music.musichallAlbum.AlbumSongList GetAlbumSongList 按 100 一批翻页(上游 fetchAlbumDetail 的曲目段)
|
||||
// 详情:GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,封面走平台固定 URL 规则
|
||||
// 链接解析:albumDetail/<mid>、album/<mid>、albummid=<mid>(上游 ParseAlbum)
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { filterMusicInfoItem } from './singer.ts'
|
||||
import { createMusicuFetch } from './utils/index.ts'
|
||||
|
||||
export default {
|
||||
/**
|
||||
* 通过专辑 mid 获取专辑详情与曲目列表
|
||||
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
async getAlbumDetail(id, page = 1, limit = 100) {
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
const ALBUM_BATCH = 100
|
||||
|
||||
export const filterSongList = (raw: any[]) =>
|
||||
(raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
|
||||
|
||||
const albumCover = (mid: string) => `https://y.gtimg.cn/music/photo_new/T002R500x500M000${mid}.jpg`
|
||||
const albumLink = (mid: string) => `https://y.qq.com/n/ryqq/albumDetail/${mid}`
|
||||
|
||||
/** 专辑曲目全量(上游 fetchAlbumDetail 的曲目段:AlbumSongList 100 一批翻页) */
|
||||
export const fetchAlbumSongs = async(albumId: string) => {
|
||||
const albumMid = String(albumId ?? '').trim()
|
||||
if (!albumMid) throw new Error('tx 专辑 id 为空')
|
||||
|
||||
const songs: any[] = []
|
||||
let totalNum = 0
|
||||
for (let begin = 0; ; begin += ALBUM_BATCH) {
|
||||
const body = await createMusicuFetch({
|
||||
req: {
|
||||
module: 'music.musichallAlbum.AlbumSongList',
|
||||
method: 'GetAlbumSongList',
|
||||
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝
|
||||
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
|
||||
param: { albumMid, begin, num: ALBUM_BATCH, order: 2 },
|
||||
},
|
||||
})
|
||||
if (body.req.code != 0) throw new Error('get album song list faild.')
|
||||
const data = body.req.data ?? {}
|
||||
const list = data.songList ?? []
|
||||
if (!totalNum) totalNum = Number(data.totalNum ?? 0) || 0
|
||||
if (!list.length) break
|
||||
songs.push(...list)
|
||||
if (list.length < ALBUM_BATCH) break
|
||||
if (totalNum > 0 && begin + list.length >= totalNum) break
|
||||
}
|
||||
const meta: any = songs[0]?.songInfo ?? null
|
||||
return {
|
||||
raw: songs,
|
||||
songs: filterSongList(songs),
|
||||
total: totalNum || songs.length,
|
||||
albumMid,
|
||||
albumName: meta?.album?.name ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const data = body.req.data
|
||||
const songList = data.songList ?? []
|
||||
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,
|
||||
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则
|
||||
const first = songList[0]?.songInfo
|
||||
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))]
|
||||
/**
|
||||
* 通过专辑 mid 获取专辑详情与曲目列表
|
||||
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
||||
* @param {*} page
|
||||
* @param {*} limit
|
||||
*/
|
||||
export const getAlbumDetail = async(id: string, page = 1, limit = 100) => {
|
||||
const body = await createMusicuFetch({
|
||||
req: {
|
||||
module: 'music.musichallAlbum.AlbumSongList',
|
||||
method: 'GetAlbumSongList',
|
||||
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝
|
||||
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
|
||||
},
|
||||
})
|
||||
if (body.req.code != 0) throw new Error('get album song list faild.')
|
||||
|
||||
const data = body.req.data
|
||||
const songList = data.songList ?? []
|
||||
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,
|
||||
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则
|
||||
const first = songList[0]?.songInfo
|
||||
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))]
|
||||
return {
|
||||
source: 'tx',
|
||||
info: {
|
||||
name: first?.album?.name ?? null,
|
||||
img: albumCover(String(id)),
|
||||
desc: null,
|
||||
author: authors.join('、') || null,
|
||||
},
|
||||
list: filterSongList(songList),
|
||||
page,
|
||||
limit,
|
||||
total: data.totalNum,
|
||||
}
|
||||
}
|
||||
|
||||
export const getAlbumSongs = async(id: string) => (await fetchAlbumSongs(id)).songs
|
||||
|
||||
/** 专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8) */
|
||||
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
|
||||
const url = `http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p=${page}&n=${limit}&w=${encodeURIComponent(String(keyword ?? ''))}&t=8`
|
||||
const { statusCode, body } = await httpFetch(url, {
|
||||
headers: { 'User-Agent': UA, Referer: 'https://y.qq.com/portal/search.html' },
|
||||
timeout: 15000,
|
||||
}).promise
|
||||
if (statusCode !== 200 || !body?.data?.album) throw new Error('tx 专辑搜索失败')
|
||||
|
||||
const rawList: any[] = body.data.album.list ?? []
|
||||
const list = rawList.map(item => {
|
||||
const mid = String(item?.albumMID ?? '').trim()
|
||||
if (!mid) return null
|
||||
return {
|
||||
source: 'tx',
|
||||
info: {
|
||||
name: first?.album?.name ?? null,
|
||||
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`,
|
||||
desc: null,
|
||||
author: authors.join('、') || null,
|
||||
},
|
||||
list: this.filterSongList(songList),
|
||||
page,
|
||||
limit,
|
||||
total: data.totalNum,
|
||||
id: mid,
|
||||
name: String(item?.albumName ?? '').trim(),
|
||||
cover: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${mid}.jpg`,
|
||||
trackCount: Number(item?.song_count ?? item?.songCount ?? 0) || 0,
|
||||
creator: String(item?.singerName ?? '').trim(),
|
||||
description: '',
|
||||
link: albumLink(mid),
|
||||
type: 'album',
|
||||
albumId: String(item?.albumID ?? ''),
|
||||
publishTime: String(item?.publicTime ?? ''),
|
||||
source: 'tx' as const,
|
||||
}
|
||||
},
|
||||
filterSongList(raw) {
|
||||
return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
|
||||
},
|
||||
}).filter(Boolean)
|
||||
const total = Number(body.data.album.totalnum ?? body.data.album.total ?? list.length) || list.length
|
||||
return { list, page, limit, total, source: 'tx' as const }
|
||||
}
|
||||
|
||||
const ALBUM_PATTERNS = [
|
||||
/albumDetail\/([A-Za-z0-9]+)/,
|
||||
/\/album\/([A-Za-z0-9]+)/,
|
||||
/albummid=([A-Za-z0-9]+)/,
|
||||
]
|
||||
|
||||
/** 专辑链接解析(上游 ParseAlbum) */
|
||||
export const parseAlbumLink = async(link: string) => {
|
||||
const raw = String(link ?? '')
|
||||
for (const pattern of ALBUM_PATTERNS) {
|
||||
const match = pattern.exec(raw)
|
||||
if (match) return getAlbumDetail(match[1]!)
|
||||
}
|
||||
throw new Error('无效的QQ音乐专辑链接')
|
||||
}
|
||||
|
||||
export default {
|
||||
getAlbumDetail,
|
||||
getAlbumSongs,
|
||||
searchAlbum,
|
||||
parseAlbumLink,
|
||||
fetchAlbumSongs,
|
||||
filterSongList,
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import hotSearch from './hotSearch.ts'
|
||||
import comment from './comment.ts'
|
||||
import singer from './singer.ts'
|
||||
import album from './album.ts'
|
||||
import playlist from './playlist.ts'
|
||||
import parse from './parse.ts'
|
||||
import userPlaylist from './userPlaylist.ts'
|
||||
|
||||
const tx = {
|
||||
leaderboard,
|
||||
@@ -16,6 +19,9 @@ const tx = {
|
||||
comment,
|
||||
singer,
|
||||
album,
|
||||
playlist,
|
||||
parse,
|
||||
userPlaylist,
|
||||
|
||||
getMusicUrl(songInfo, type) {
|
||||
return txApi.getMusicUrl(songInfo, type)
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// QQ音乐链接解析(上游 music-lib/qq/song.go 的 Parse 移植)
|
||||
// 歌曲链接形态:
|
||||
// https://y.qq.com/n/ryqq/songDetail/<songmid>
|
||||
// https://y.qq.com/n/yqq/song/<songmid>.html
|
||||
// https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 songmid)
|
||||
// ...?songmid=<songmid>
|
||||
// 返回 Song(含 songmid);直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致)。
|
||||
import { httpFetch } from '../request.ts'
|
||||
import { formatPlayTime, sizeFormate } from '../index.ts'
|
||||
import { formatSingerName } from '../utils.ts'
|
||||
import txApi from '../apis/tx.ts'
|
||||
import { parseAlbumLink } from './album.ts'
|
||||
import { parsePlaylistLink } from './playlist.ts'
|
||||
|
||||
const SONG_DETAIL_RE = /songDetail\/([A-Za-z0-9]+)/
|
||||
const SONG_PATH_RE = /\/song\/([A-Za-z0-9]+)/
|
||||
const SONG_MID_QUERY_RE = /[?&]songmid=([A-Za-z0-9]+)/
|
||||
const SONG_ID_QUERY_RE = /[?&]songid=(\d+)/
|
||||
|
||||
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1'
|
||||
|
||||
/** 上游 fetchSongDetailByID:数值 songid → mid */
|
||||
const fetchMidBySongId = async(songid: string) => {
|
||||
const { statusCode, body } = await httpFetch(
|
||||
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songid=${encodeURIComponent(songid)}&format=json`,
|
||||
{ headers: { 'User-Agent': UA, Referer: 'http://m.y.qq.com' }, timeout: 15000 },
|
||||
).promise
|
||||
const mid = statusCode === 200 ? body?.data?.[0]?.mid : ''
|
||||
if (!mid) throw new Error('QQ音乐歌曲不存在')
|
||||
return String(mid)
|
||||
}
|
||||
|
||||
/** 上游 fetchSongDetail:songmid → 原始条目(顺带拿 file 体积信息) */
|
||||
const fetchSongDetailRaw = async(songmid: string) => {
|
||||
const { statusCode, body } = await httpFetch(
|
||||
`https://c.y.qq.com/v8/fcg-bin/fcg_play_single_song.fcg?songmid=${encodeURIComponent(songmid)}&format=json`,
|
||||
{ headers: { 'User-Agent': UA, Referer: 'http://m.y.qq.com' }, timeout: 15000 },
|
||||
).promise
|
||||
const item = statusCode === 200 ? body?.data?.[0] : null
|
||||
if (!item?.mid) throw new Error('QQ音乐歌曲不存在')
|
||||
return item
|
||||
}
|
||||
|
||||
/** 原始条目 → MusicServer Song 形状 */
|
||||
const convertSong = (item: any) => {
|
||||
const file = item?.file ?? {}
|
||||
const types: any[] = []
|
||||
const _types: Record<string, { size: string }> = {}
|
||||
const push = (type: string, size: any) => {
|
||||
if (typeof size === 'number' && size > 0) {
|
||||
const formatted = sizeFormate(size)
|
||||
types.push({ type, size: formatted })
|
||||
_types[type] = { size: formatted }
|
||||
}
|
||||
}
|
||||
push('128k', file.size_128mp3)
|
||||
push('320k', file.size_320mp3)
|
||||
push('flac', file.size_flac)
|
||||
push('flac24bit', file.size_hires)
|
||||
|
||||
const albumMid = item?.album?.mid ?? ''
|
||||
const singer = formatSingerName(item?.singer, 'name')
|
||||
return {
|
||||
source: 'tx' as const,
|
||||
songmid: String(item.mid),
|
||||
songId: item.id,
|
||||
name: item.title ?? item.name ?? '',
|
||||
singer,
|
||||
albumId: albumMid,
|
||||
albumMid,
|
||||
albumName: item?.album?.name ?? '',
|
||||
interval: formatPlayTime(item?.interval ?? 0),
|
||||
img: albumMid ? `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumMid}.jpg` : '',
|
||||
lrc: null,
|
||||
strMediaMid: file.media_mid ?? item.mid,
|
||||
types,
|
||||
_types,
|
||||
typeUrl: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
|
||||
export const parseSong = async(link: string) => {
|
||||
const raw = String(link ?? '')
|
||||
let songmid = SONG_DETAIL_RE.exec(raw)?.[1] ?? SONG_PATH_RE.exec(raw)?.[1] ?? ''
|
||||
let songid = ''
|
||||
if (!songmid) {
|
||||
songid = SONG_ID_QUERY_RE.exec(raw)?.[1] ?? ''
|
||||
songmid = SONG_MID_QUERY_RE.exec(raw)?.[1] ?? ''
|
||||
if (!songmid && songid) songmid = await fetchMidBySongId(songid)
|
||||
}
|
||||
if (!songmid) throw new Error('无效的QQ音乐歌曲链接')
|
||||
|
||||
const item = await fetchSongDetailRaw(songmid)
|
||||
const song: any = convertSong(item)
|
||||
if (songid) song.songId = Number(songid)
|
||||
try {
|
||||
const hit = await txApi.getMusicUrl(song, '320k').promise
|
||||
song.url = hit.url
|
||||
song.type = hit.type
|
||||
} catch {
|
||||
// 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据)
|
||||
}
|
||||
return song
|
||||
}
|
||||
|
||||
export const parseAlbum = async(link: string) => parseAlbumLink(link)
|
||||
export const parsePlaylist = async(link: string) => parsePlaylistLink(link)
|
||||
|
||||
export default { parseSong, parseAlbum, parsePlaylist }
|
||||
@@ -0,0 +1,272 @@
|
||||
// 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,
|
||||
}
|
||||
@@ -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