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:
snowy-yang committed 2026-10-03 00:26:51 +08:00
1 parent 33b96f92c4
commit 68e071b166
20 files changed
+2218 -33

No files matched your search

+8 -2
View File
@@ -15,6 +15,7 @@
// 不匹配视为未命中、沿降级链换档重试; // 不匹配视为未命中、沿降级链换档重试;
// - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合, // - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合,
// 本组合已验证为真音频。 // 本组合已验证为真音频。
// - 返回 url 的 query 用 `$` 顶替 `=`,统一还原后对外返回(见 normalizeUrl)。
// 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头) // 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
// 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发), // 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发),
// 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法, // 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法,
@@ -48,6 +49,11 @@ const fallbackChain: Record<string, string[]> = {
// 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播 // 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播
const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a']) const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a'])
// 酷我 CDN 回吐的 query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`,实为酷我客户端
// 自有写法,签名 token 在 path 里、query 无 sign 参数),`$` 非标准分隔符,播放器与 URL
// 解析器普遍不认,统一还原成 `=` 再对外返回
const normalizeUrl = (url: string) => url.replace(/\$/g, '=')
// 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality) // 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality)
export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit'] export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit']
@@ -68,7 +74,7 @@ const getUrlByMobi = (songmid: string, tier: { br: string, bitrate: number }): P
const data = body?.data const data = body?.data
if (statusCode != 200 || !data?.url) return null if (statusCode != 200 || !data?.url) return null
if (Number(data.bitrate) != tier.bitrate || !plainFormats.has(String(data.format))) return null if (Number(data.bitrate) != tier.bitrate || !plainFormats.has(String(data.format))) return null
return { url: data.url } return { url: normalizeUrl(data.url) }
}).catch(() => null) }).catch(() => null)
} }
@@ -156,7 +162,7 @@ const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => {
return Promise.reject(new Error(msg || '获取失败')) return Promise.reject(new Error(msg || '获取失败'))
} }
// 服务器无视 br,匿名实际只回 128k,如实上报 // 服务器无视 br,匿名实际只回 128k,如实上报
return { type: '128k', url: body.data.url } return { type: '128k', url: normalizeUrl(body.data.url) }
}) })
return requestObj return requestObj
} }
+63
View File
@@ -1,5 +1,8 @@
import { getMusicInfosByList } from './musicInfo.ts' import { getMusicInfosByList } from './musicInfo.ts'
import { createHttpFetch } from './util.ts' import { createHttpFetch } from './util.ts'
import { httpFetch } from '../request.ts'
const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240')
export default { export default {
/** /**
@@ -60,4 +63,64 @@ export default {
}, },
} }
}, },
/**
* 专辑搜索(上游 kugou SearchAlbum:mobilecdn v3 search/album)
* 返回摘要统一形状(对齐契约:id/name/cover/trackCount/creator/description/link/source)
*/
async searchAlbum(keyword, page = 1, limit = 10) {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kg', list: [], page, limit, total: 0 }
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 10)
const apiURL = `http://mobilecdn.kugou.com/api/v3/search/album?keyword=${encodeURIComponent(word)}&format=json&page=${pageNo}&pagesize=${pageSize}`
const { statusCode, body } = await httpFetch(apiURL, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1',
Referer: 'http://m.kugou.com',
},
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗专辑搜索失败')
}
const list = (body?.data?.info ?? []).map((item: any) => {
const albumID = String(item?.albumid ?? '').trim()
if (!albumID || albumID === '0') return null
return {
id: albumID,
name: String(item?.albumname ?? '').trim(),
cover: albumCover(item?.imgurl),
trackCount: Number(item?.songcount ?? 0) || 0,
creator: String(item?.singername ?? '').trim(),
description: String(item?.intro ?? '').trim(),
publishTime: String(item?.publishtime ?? '').trim(),
link: `https://www.kugou.com/album/${albumID}.html`,
source: 'kg',
type: 'album',
}
}).filter(Boolean)
return { source: 'kg', list, page: pageNo, limit: pageSize, total: Number(body?.data?.total ?? 0) || list.length }
},
/** 上游 GetAlbumSongs:复用 getAlbumDetail 拉全量曲目 */
async getAlbumSongs(id) {
const albumID = String(id ?? '').trim()
if (!albumID) throw new Error('酷狗专辑 id 为空')
const { list } = await this.getAlbumDetail(albumID, 1, 10000)
return list
},
/** 上游 ParseAlbum:album/single/<id>.html、album/<id>.html、albumid=<id> */
async parseAlbumLink(link) {
const raw = String(link ?? '')
const patterns = [
/album\/single\/(\d+)\.html/,
/yy\/album\/single\/(\d+)\.html/,
/album\/(\d+)\.html/,
/albumid=(\d+)/,
]
for (const pattern of patterns) {
const match = raw.match(pattern)
if (match) return this.getAlbumDetail(match[1], 1)
}
throw new Error('无效的酷狗专辑链接')
},
} }
+4
View File
@@ -8,6 +8,8 @@ import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts' import singer from './singer.ts'
import album from './album.ts' import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import { registerDfid } from '../auth/kg.ts' import { registerDfid } from '../auth/kg.ts'
const kg = { const kg = {
@@ -22,6 +24,8 @@ const kg = {
comment, comment,
singer, singer,
album, album,
playlist,
parse,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return kgApi.getMusicUrl(songInfo, type) return kgApi.getMusicUrl(songInfo, type)
}, },
+111
View File
@@ -0,0 +1,111 @@
// 酷狗链接解析
// 歌曲:https://www.kugou.com/song/#hash=<32位hash>(含 &album_id=... 等尾串)、裸 hash
// 上游对应:music-lib/kugou/song.go Parse / kugou.go fetchSongInfo
// 元数据 m.kugou.com/app/i/getSongInfo.php(songName/author_name/timeLength/album_img/bitRate)
// 另尽力合并 musicInfo.getMusicInfo 的专辑/多档 hash,直链取流失败不影响返回(上游为必得 URL)。
import { httpFetch } from '../request.ts'
import { formatPlayTime, decodeName } from '../index.ts'
import kgApi from '../apis/kg.ts'
import { getMusicInfo } from './musicInfo.ts'
import album from './album.ts'
import playlist from './playlist.ts'
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
const HASH_RE = /hash=([a-fA-F0-9]{32})/i
const RAW_HASH_RE = /^[a-fA-F0-9]{32}$/
/** 链接/裸 hash → 32 位小写 hash(上游 Parse 的 `(?i)hash=([a-f0-9]{32})`,另接受裸 hash) */
export const extractHash = (link: any): string => {
const raw = String(link ?? '').trim()
const match = raw.match(HASH_RE)
if (match) return match[1]!.toLowerCase()
return RAW_HASH_RE.test(raw) ? raw.toLowerCase() : ''
}
/** 上游 fetchSongInfo:m.kugou.com playInfo(返回元数据 + 直链,付费曲无 url) */
const fetchSongInfoPhp = async(hash: string) => {
const { statusCode, body } = await httpFetch(`http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash=${hash}`, {
method: 'get',
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || typeof body !== 'object' || body == null) return null
if (Number(body.errcode ?? 0) !== 0 || !body.hash) return null
return body
}
/** 上游 Parse:酷狗歌曲链接(或裸 hash)→ Song,尽力附带直链 */
export const parseSong = async(link: string) => {
const hash = extractHash(link)
if (!hash) throw new Error('无效的酷狗歌曲链接(未找到 32 位 hash)')
let [raw, info] = await Promise.all([
fetchSongInfoPhp(hash).catch(() => null),
getMusicInfo(hash).catch(() => undefined),
])
if (!raw && !info) throw new Error('酷狗歌曲不存在或已下架')
const fileName = String(raw?.fileName ?? '')
const name = decodeName(String(raw?.songName ?? '').trim() || fileName.split(' - ').slice(1).join(' - ') || info?.name || '')
const singer = decodeName(String(raw?.author_name ?? '').trim() || info?.singer || '')
const duration = Number(raw?.timeLength ?? 0) || 0
const song: any = {
songmid: String(info?.songmid ?? raw?.audio_id ?? '').trim(),
hash: String(raw?.hash ?? hash).toLowerCase(),
name,
singer,
albumName: decodeName(String(info?.albumName ?? '').trim()),
albumId: String(info?.albumId ?? raw?.albumid ?? raw?.req_albumid ?? '').trim(),
interval: duration > 0 ? formatPlayTime(duration) : (info?.interval ?? '--/--'),
img: String(raw?.album_img ?? '').replace('{size}', '240') || info?.img || null,
lrc: null,
otherSource: null,
types: info?.types ?? [],
_types: info?._types ?? {},
typeUrl: {},
source: 'kg' as const,
extra: {
hash: String(raw?.hash ?? hash).toLowerCase(),
url: String(raw?.url ?? ''),
backup_url: raw?.backup_url ?? [],
bitrate: Number(raw?.bitRate ?? 0) || 0,
link: `https://www.kugou.com/song/#hash=${hash}`,
},
}
// 上游 fetchSongInfo 必得 URL:playInfo 的 url/backup_url 优先(官方直链,付费曲为空)
if (!song.url) {
const rawUrl = String(raw?.url ?? '') || String(raw?.backup_url?.[0] ?? '')
if (rawUrl) {
song.url = rawUrl
song.type = Number(raw?.bitRate ?? 0) >= 320000 ? '320k' : '128k'
}
}
// 仍无直链时(风控/付费)用 apis/kg 的签名通道尽力补一次;失败仅返回元数据
if (!song.url) {
try {
const hit = await kgApi.getMusicUrl({
hash: song.hash,
albumId: song.albumId,
songmid: song.songmid,
_types: song._types,
} as any, '128k').promise
if (hit?.url) {
song.url = hit.url
song.type = hit.type
}
} catch {
// 忽略:与上游付费曲无直链的失败语义一致(元数据仍返回)
}
}
return song
}
export const parseAlbum = async(link: string) => album.parseAlbumLink(link)
export const parsePlaylist = async(link: string) => playlist.parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+237
View File
@@ -0,0 +1,237 @@
// 酷狗歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
// 上游对应:music-lib/kugou/playlist.go + kugou.go 的 fetchPlaylistDetail
// 链接解析优先复用既有 sdk/kg/songList.ts 的 getUserListDetail / getListDetailBySpecialId /
// getListInfoByChain / decodeGcid 等能力(覆盖 special/single、songlist/gcid、share chain、
// zlist、global_collection_id 等形态),本模块只做统一形状归一。
import { httpFetch } from '../request.ts'
import songList from './songList.ts'
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1'
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
const playlistCover = (url: any): string => String(url ?? '').replace('{size}', '240')
/** 歌单摘要(对齐契约形状;id 用平台 specialid,link 指向网页版) */
const toSummary = (item: any) => {
const rawId = firstNonEmpty(item?.specialid, item?.global_specialid, item?.id)
const id = rawId.replace(/^id_/, '')
if (!id) return null
const playCount = Number(item?.playcount)
return {
id,
name: firstNonEmpty(item?.specialname, item?.name),
cover: playlistCover(firstNonEmpty(item?.imgurl, item?.img)),
trackCount: Number(item?.songcount ?? item?.total ?? 0) || 0,
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
creator: firstNonEmpty(item?.nickname, item?.username, item?.author),
description: firstNonEmpty(item?.intro, item?.desc),
publishTime: String(item?.publishtime ?? item?.time ?? '').trim(),
link: `https://www.kugou.com/yy/special/single/${id}.html`,
source: 'kg' as const,
}
}
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 msearchretry v3 接口) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kg' as const, list: [] as any[], page, limit, total: 0 }
const res = await songList.search(word, page, limit)
const list = (res?.list ?? []).map(toSummary).filter(Boolean)
return {
source: 'kg' as const,
list,
page,
limit,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 数字 id / id_ 前缀 / 链接 → songList.getListDetail 可识别的入参 */
const normalizePlaylistId = (id: any): string => {
const raw = String(id ?? '').trim()
if (!raw) return ''
if (/^\d+$/.test(raw)) return `id_${raw}`
return raw
}
/**
* 歌单详情(含元数据与曲目)
* 委托 songList.getListDetail(special 页 / gcid songlist / share chain / 裸酷狗码等),
* 平台自身返回全量时按 page/limit 本地开窗。
*/
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const requestId = normalizePlaylistId(id)
if (!requestId) throw new Error('酷狗歌单 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const res = await songList.getListDetail(requestId, pageNo)
if (!res) throw new Error('酷狗歌单不存在或解析失败')
let list: any[] = res.list ?? []
const total = Number(res.total ?? 0) || list.length
if (list.length > pageSize) {
const start = (pageNo - 1) * pageSize
list = list.slice(start, start + pageSize)
}
return {
source: 'kg' as const,
info: {
name: firstNonEmpty(res.info?.name, String(id)),
img: playlistCover(res.info?.img),
desc: firstNonEmpty(res.info?.desc),
author: firstNonEmpty(res.info?.author),
play_count: res.info?.play_count ?? '',
},
list,
page: pageNo,
limit: pageSize,
total,
}
}
/** 上游 GetPlaylistSongs:只取曲目(复用详情全量) */
export const getPlaylistSongs = async(id: string) => {
const res = await songList.getListDetail(normalizePlaylistId(id), 1)
return res?.list ?? []
}
/** 歌单分类(上游 GetPlaylistCategories:tag/list;categoryId 形如 `<id>:<tagId>`) */
export const getCategories = async() => {
const { statusCode, body } = await httpFetch('http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0', {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗歌单分类获取失败')
}
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
{ id: '', name: '全部', group: '全部' },
]
for (const group of body?.data?.info ?? []) {
const groupName = String(group?.name ?? '').trim()
const groupID = Number(group?.id ?? 0) || 0
if (groupID > 0 && groupName) {
list.push({ id: `${groupID}:0`, name: groupName, group: groupName })
}
for (const child of group?.children ?? []) {
const id = Number(child?.id ?? 0) || 0
const name = String(child?.name ?? '').trim()
if (!id || !name) continue
const tagId = Number(child?.special_tag_id ?? 0) || 0
list.push({ id: `${id}:${tagId}`, name, group: groupName, hot: Number(child?.is_hot ?? 0) === 1 })
}
}
return { source: 'kg' as const, list }
}
/** categoryId → [id, tagId];空/dirty 时取分类表首个非空分类 */
const parseCategoryID = (categoryId: any): { id: string, tagId: string } => {
const parts = String(categoryId ?? '').trim().split(':')
const id = String(parts[0] ?? '').trim()
const tagId = String(parts[1] ?? '').trim() || '0'
return { id, tagId }
}
/** 分类歌单(上游 GetCategoryPlaylists:tag/specialList) */
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 20)
let { id, tagId } = parseCategoryID(categoryId)
if (!id) {
const { list } = await getCategories()
const first = list.find(item => item.id)
if (!first) throw new Error('酷狗歌单分类为空')
;({ id, tagId } = parseCategoryID(first.id))
}
const apiURL = 'http://mobilecdnbj.kugou.com/api/v3/tag/specialList?' + [
'plat=0',
`page=${pageNo}`,
`tagid=${tagId}`,
`pagesize=${pageSize}`,
'ugc=1',
`id=${id}`,
'sort=2',
].join('&')
const { statusCode, body } = await httpFetch(apiURL, {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.status !== 1 || body?.errcode !== 0) {
throw new Error(body?.error || '酷狗分类歌单获取失败')
}
const list = (body?.data?.info ?? []).map((item: any) => {
const summary: any = toSummary(item) ?? toSummary({ ...item, specialid: item?.global_specialid })
if (!summary) return null
summary.categoryId = `${id}:${tagId}`
return summary
}).filter(Boolean)
return {
source: 'kg' as const,
list,
page: pageNo,
limit: pageSize,
total: Number(body?.data?.total ?? 0) || list.length,
}
}
/** 推荐歌单(上游 GetRecommendedPlaylists:m.kugou.com/plist/index&json=true) */
export const getRecommended = async(page = 1, limit = 30) => {
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 30)
const { statusCode, body } = await httpFetch(`http://m.kugou.com/plist/index&json=true&page=${pageNo}`, {
headers: { 'User-Agent': UA, Referer: 'http://m.kugou.com' },
timeout: 15000,
}).promise
if (statusCode !== 200 || typeof body !== 'object' || body == null) {
throw new Error('酷狗推荐歌单获取失败')
}
let list = (body?.plist?.list?.info ?? []).map(toSummary).filter(Boolean)
if (list.length > pageSize) list = list.slice(0, pageSize)
return {
source: 'kg' as const,
list,
page: pageNo,
limit: pageSize,
total: Number(body?.plist?.list?.total ?? 0) || list.length,
}
}
/**
* 上游 ParsePlaylist:
* /yy/special/single/<id>.html → special 详情
* /songlist/<gcid_xxx>/ → gcid 详情
* 其余链接形态(share chain、zlist、global_collection_id、裸 id/酷狗码)交给 songList 通用解析。
*/
export const parsePlaylistLink = async(link: string) => {
const raw = String(link ?? '').trim()
if (!raw) throw new Error('无效的酷狗歌单链接')
const special = raw.match(/special\/single\/(\d+)\.html/)
const gcid = raw.match(/songlist\/(gcid_[A-Za-z0-9]+)/)
if (special) return getPlaylistDetail(special[1]!, 1)
if (gcid) return getPlaylistDetail(gcid[1]!, 1)
return getPlaylistDetail(raw, 1)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
parsePlaylistLink,
}
+133
View File
@@ -3,6 +3,15 @@ import { decodeName } from '../index.ts'
import { formatSinger, objStr2JSON } from './util.ts' import { formatSinger, objStr2JSON } from './util.ts'
import type { Quality } from '../types.ts' import type { Quality } from '../types.ts'
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
// let requestObj_list // let requestObj_list
export default { export default {
limit_list: 36, limit_list: 36,
@@ -102,6 +111,130 @@ export default {
} }
}) })
}, },
/**
* 专辑详情(统一形状;服务端 1000 条/页,limit 更小时本地按 page/limit 开窗)
* 上游对应:kuwo.go fetchAlbumDetail(legacy API 部分)
*/
async getAlbumDetail(id: string, page = 1, limit = 1000) {
const albumID = String(id ?? '').trim()
if (!albumID) throw new Error('酷我专辑 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || this.limit_song)
if (pageSize >= this.limit_song) return this.getAlbumListDetail(albumID, pageNo)
const start = (pageNo - 1) * pageSize
let serverPage = Math.floor(start / this.limit_song) + 1
let skip = start % this.limit_song
const list: any[] = []
let info: any = null
let total = 0
while (list.length < pageSize && serverPage <= 50) {
const res = await this.getAlbumListDetail(albumID, serverPage)
if (!res) break
if (!info) info = res.info ?? null
total = Number(res.total ?? 0) || total
const rawList: any[] = res.list ?? []
if (!rawList.length) break
list.push(...rawList.slice(skip, skip + (pageSize - list.length)))
skip = 0
if (rawList.length < this.limit_song) break
if (total > 0 && list.length >= total) break
serverPage += 1
}
return {
source: 'kw',
info: info ?? { name: albumID, img: '', desc: '', author: '' },
list,
page: pageNo,
limit: pageSize,
total: total || list.length,
}
},
/** 上游 GetAlbumSongs:聚合全部分页曲目(去重) */
async getAlbumSongs(id: string) {
const albumID = String(id ?? '').trim()
if (!albumID) throw new Error('酷我专辑 id 为空')
const songs: any[] = []
const seen = new Set<string>()
for (let page = 1; page <= 50; page++) {
const res = await this.getAlbumListDetail(albumID, page)
const rawList: any[] = res?.list ?? []
for (const song of rawList) {
const key = String(song?.songmid ?? '')
if (key && seen.has(key)) continue
if (key) seen.add(key)
songs.push(song)
}
const total = Number(res?.total ?? 0) || 0
if (rawList.length < this.limit_song) break
if (total > 0 && songs.length >= total) break
}
return songs
},
/**
* 专辑搜索(上游 SearchAlbum,legacy r.s ft=album 路由;返回歌单/专辑统一摘要形状)
*/
async searchAlbum(keyword: string, page = 1, limit = 10) {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kw' as const, list: [] as any[], page, limit, total: 0 }
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 10)
const apiURL = 'http://search.kuwo.cn/r.s?' + [
`all=${encodeURIComponent(word)}`,
'ft=album',
'itemset=web_2013',
'client=kt',
'pcmp4=1',
'geo=c',
'vipver=1',
`pn=${pageNo - 1}`,
`rn=${pageSize}`,
'rformat=json',
'encoding=utf8',
].join('&')
const { statusCode, body } = await httpFetch(apiURL).promise
if (statusCode !== 200) throw new Error('酷我专辑搜索失败')
const data = typeof body === 'string' ? (() => { try { return objStr2JSON(body) } catch { return null } })() : body
if (!data?.albumlist) throw new Error('酷我专辑搜索失败')
const list = (data.albumlist as any[]).map(item => {
const albumID = firstNonEmpty(item.albumid, item.id)
if (!albumID) return null
return {
id: albumID,
name: decodeName(String(item.name ?? '').trim()),
cover: firstNonEmpty(item.hts_img, item.img),
trackCount: parseInt(String(item.musiccnt ?? ''), 10) || 0,
playCount: parseInt(String(item.PLAYCNT ?? item.playcnt ?? ''), 10) || 0,
creator: decodeName(firstNonEmpty(item.aartist, item.artist)),
description: decodeName(String(item.info ?? '').trim()),
link: `http://www.kuwo.cn/album_detail/${albumID}`,
source: 'kw' as const,
type: 'album',
company: decodeName(String(item.company ?? '').trim()),
publishTime: String(item.pub ?? '').trim(),
}
}).filter(Boolean)
return {
source: 'kw' as const,
list,
page: pageNo,
limit: pageSize,
total: parseInt(String(data.TOTAL ?? data.total ?? ''), 10) || list.length,
}
},
/** 上游 ParseAlbum:album_detail/<id>、album/<id>、albumid=<id> */
async parseAlbumLink(link: string) {
const raw = String(link ?? '')
const patterns = [/album_detail\/(\d+)/, /album\/(\d+)/, /albumid=(\d+)/]
for (const pattern of patterns) {
const match = raw.match(pattern)
if (match) return this.getAlbumDetail(match[1]!, 1)
}
throw new Error('无效的酷我专辑链接')
},
// getAlbumListDetail(id, page, retryNum = 0) { // getAlbumListDetail(id, page, retryNum = 0) {
// if (retryNum > 2) return Promise.reject(new Error('try max num')) // if (retryNum > 2) return Promise.reject(new Error('try max num'))
// return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => { // return tokenRequest(`http://www.kuwo.cn/api/www/album/albumInfo?albumId=${id}&pn=${page}&rn=${this.limit_song}&httpsStatus=1`).then((resp) => {
+4
View File
@@ -9,6 +9,8 @@ import songList from './songList.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import album from './album.ts' import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import type { HttpRequestObject } from '../types.ts' import type { HttpRequestObject } from '../types.ts'
const kw = { const kw = {
@@ -17,6 +19,8 @@ const kw = {
_musicPicRequestObj: null as HttpRequestObject | null, _musicPicRequestObj: null as HttpRequestObject | null,
_musicPicPromiseCancelFn: null, _musicPicPromiseCancelFn: null,
album, album,
playlist,
parse,
// context: null, // context: null,
+107
View File
@@ -0,0 +1,107 @@
// 酷我链接解析
// 歌曲:https://www.kuwo.cn/play_detail/<rid>(含 ?/# 尾串)、m.kuwo.cn 的 musicId=<rid>、裸 rid
// 上游对应:music-lib/kuwo/song.go Parse / kuwo.go fetchFullSongInfo(元数据 m.kuwo.cn + 尽力附直链)
// 专辑 / 歌单分别复用 album.parseAlbumLink、playlist.parsePlaylistLink。
import { httpFetch } from '../request.ts'
import { formatPlayTime } from '../index.ts'
import kwApi from '../apis/kw.ts'
import album from './album.ts'
import playlist from './playlist.ts'
import { formatSinger } from './util.ts'
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
const SONG_PATTERNS = [
/play_detail\/(\d+)/, // http://www.kuwo.cn/play_detail/228908
/h5app\/song\/(\d+)/, // https://m.kuwo.cn/h5app/song/228908
/musicId=(\d+)/, // http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=228908
/^\d{4,}$/, // 裸 rid
]
const extractSongId = (link: any): string => {
const raw = String(link ?? '').trim()
for (const pattern of SONG_PATTERNS) {
const match = raw.match(pattern)
if (match) return match[1] ?? raw
}
return ''
}
interface KuwoSongMeta {
songName?: string
artist?: string
pic?: string
album?: string
albumId?: string | number | null
duration?: string | number
}
/** 拉取歌曲元数据(上游 fetchFullSongInfo 的 songinfoandlrc 部分;接口偶发 status 301,重试 3 次) */
const fetchSongMeta = async(rid: string, retryNum = 0): Promise<KuwoSongMeta> => {
const { body } = await httpFetch(`http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${rid}&httpsStatus=1`, {
method: 'get',
headers: { 'User-Agent': UA, Cookie: 'wd=1;vip=0' },
timeout: 15000,
}).promise
let data: any = body
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
data = null
}
}
const songinfo = data?.data?.songinfo
if (!songinfo) {
// 风控/抖动时上游返回 status 301「音乐查询失败」,退避重试(最多 5 次,约 9s)
if (retryNum < 5) {
await new Promise(resolve => setTimeout(resolve, 600 * (retryNum + 1)))
return fetchSongMeta(rid, ++retryNum)
}
throw new Error(data?.msg || '酷我歌曲不存在或已下架')
}
return songinfo
}
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type,失败不影响返回) */
export const parseSong = async(link: string) => {
const rid = extractSongId(link)
if (!rid) throw new Error('无效的酷我歌曲链接')
const meta = await fetchSongMeta(rid)
const duration = parseInt(String(meta.duration ?? ''), 10) || 0
const song: any = {
songmid: rid,
name: meta.songName || `Kuwo_Song_${rid}`,
singer: formatSinger(String(meta.artist ?? '').trim()),
albumName: String(meta.album ?? '').trim(),
albumId: meta.albumId != null ? String(meta.albumId) : '',
interval: duration > 0 ? formatPlayTime(duration) : '--/--',
img: meta.pic || null,
lrc: null,
otherSource: null,
types: [],
_types: {},
typeUrl: {},
source: 'kw' as const,
extra: { rid, link: `http://www.kuwo.cn/play_detail/${rid}` },
}
try {
const hit = await kwApi.getMusicUrl({ songmid: rid } as any, '128k').promise
if (hit?.url) {
song.url = hit.url
song.type = hit.type
}
} catch {
// 与上游 fetchAudioURL 降级一致:直链获取失败(版权/风控)仍返回元数据
}
return song
}
export const parseAlbum = async(link: string) => album.parseAlbumLink(link)
export const parsePlaylist = async(link: string) => playlist.parsePlaylistLink(link)
export default { parseSong, parseAlbum, parsePlaylist }
+217
View File
@@ -0,0 +1,217 @@
// 酷我歌单 —— 搜索 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析
// 上游对应:music-lib/kuwo/playlist.go
// 复用既有内部实现(不重复造轮子):
// - 搜索:songList.search
// - 详情:songList.getListDetail(nplserver,服务端固定 1000 条/页,本地按 page/limit 开窗)
// - 分类:songList.getTag / getHotTag(同上游 getTagList / getRcmTagList)
// - 分类歌单 / 推荐:songList.getList(wapi getTagPlayList / getRcmPlayList)
// 上游 GetRecommendedPlaylists 用 getRcmPlayList(order=hot) 且 limit=30;既有 getList 的
// limit_list=36(模块常量),故推荐条数以 36 为准,显式 limit 更小时本地截断。
import songList from './songList.ts'
// songList.getListDetailUrl 固定 rn=limit_song=1000
const SERVER_PAGE_SIZE = 1000
const MAX_SERVER_PAGES = 60
const firstNonEmpty = (...values: any[]): string => {
for (const value of values) {
if (value == null) continue
const str = String(value).trim()
if (str) return str
}
return ''
}
/** 歌单链接 / digest 前缀 id → 平台内部 id(digest 前缀形态原样保留,交给 songList 分流) */
const normalizePlaylistId = (id: any): string => {
const raw = String(id ?? '').trim()
if (!raw) return ''
const patterns = [
/playlist_detail\/(\d+)/, // http://www.kuwo.cn/playlist_detail/2886046289
/\/playlist\/(\d+)/, // https://m.kuwo.cn/h5app/playlist/2736267853?t=qqfriend
/playlistId=(\d+)/,
]
for (const pattern of patterns) {
const match = raw.match(pattern)
if (match) return match[1]!
}
return raw
}
/** 歌单摘要(对齐契约形状:id/name/cover/trackCount/playCount/creator/description/link/source) */
const toSummary = (item: any) => {
const id = String(item?.id ?? '').trim()
const plainId = id.replace(/^digest-\w+__/, '')
const playCount = Number(item?.play_count)
return {
...item,
id,
name: String(item?.name ?? '').trim(),
cover: firstNonEmpty(item?.img, item?.cover),
trackCount: Number(item?.total ?? item?.trackCount ?? 0) || 0,
playCount: Number.isFinite(playCount) ? playCount : (item?.play_count ?? 0),
creator: firstNonEmpty(item?.author, item?.creator, item?.uname),
description: firstNonEmpty(item?.desc, item?.description),
link: plainId ? `http://www.kuwo.cn/playlist_detail/${plainId}` : '',
source: 'kw' as const,
}
}
/** 歌单搜索(上游 SearchPlaylist;复用 songList.search 的 legacy r.s 接口) */
export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => {
const word = String(keyword ?? '').trim()
if (!word) return { source: 'kw' as const, list: [] as any[], page, limit, total: 0 }
const res = await songList.search(word, page, limit)
const list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
return {
source: 'kw' as const,
list,
page,
limit,
total: Number(res?.total ?? 0) || list.length,
}
}
/**
* 歌单详情(含元数据与曲目)
* 服务端 1000 条/页,按调用方 page/limit 开窗,只抓所需的 1~2 个服务端页。
*/
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const playlistId = normalizePlaylistId(id)
if (!playlistId) throw new Error('酷我歌单 id 为空')
const pageNo = Math.max(1, Number(page) || 1)
const pageSize = Math.max(1, Number(limit) || 50)
const start = (pageNo - 1) * pageSize
let serverPage = Math.floor(start / SERVER_PAGE_SIZE) + 1
let skip = start % SERVER_PAGE_SIZE
const list: any[] = []
let info: any = null
let total = 0
while (list.length < pageSize && serverPage <= MAX_SERVER_PAGES) {
const res = await songList.getListDetail(playlistId, serverPage)
if (!res) break
if (!info) info = res.info ?? null
total = Number(res.total ?? 0) || total
const rawList: any[] = res.list ?? []
if (!rawList.length) break
list.push(...rawList.slice(skip, skip + (pageSize - list.length)))
skip = 0
if (rawList.length < SERVER_PAGE_SIZE) break
if (total > 0 && serverPage * SERVER_PAGE_SIZE >= total) break
serverPage += 1
}
return {
source: 'kw' as const,
info: {
name: firstNonEmpty(info?.name, playlistId),
img: firstNonEmpty(info?.img),
desc: firstNonEmpty(info?.desc),
author: firstNonEmpty(info?.author),
play_count: info?.play_count ?? '',
},
list,
page: pageNo,
limit: pageSize,
total: total || list.length,
}
}
/** 上游 GetPlaylistSongs:聚合全部页曲目(去重) */
export const getPlaylistSongs = async(id: string) => {
const playlistId = normalizePlaylistId(id)
if (!playlistId) throw new Error('酷我歌单 id 为空')
const songListResult: any[] = []
const seen = new Set<string>()
for (let serverPage = 1; serverPage <= MAX_SERVER_PAGES; serverPage++) {
const res = await songList.getListDetail(playlistId, serverPage)
const rawList: any[] = res?.list ?? []
for (const song of rawList) {
const key = String(song?.songmid ?? '')
if (key && seen.has(key)) continue
if (key) seen.add(key)
songListResult.push(song)
}
const total = Number(res?.total ?? 0) || 0
if (rawList.length < SERVER_PAGE_SIZE) break
if (total > 0 && songListResult.length >= total) break
}
return songListResult
}
/** 歌单分类(上游 GetPlaylistCategories;复用 getTag/getHotTag,tagId 形如 `<id>-<digest>`) */
export const getCategories = async() => {
const { tags, hotTag } = await songList.getTags()
const hotIds = new Set((hotTag ?? []).map((item: any) => String(item?.id ?? '')))
const list: Array<{ id: string, name: string, group: string, hot?: boolean }> = [
{ id: '', name: '全部', group: '全部' },
]
for (const group of tags ?? []) {
for (const item of group?.list ?? []) {
const id = String(item?.id ?? '').trim()
const name = String(item?.name ?? '').trim()
if (!id || !name) continue
list.push({ id, name, group: String(group?.name ?? '').trim() || '其他', hot: hotIds.has(id) })
}
}
return { source: 'kw' as const, list }
}
/**
* 分类歌单(上游 GetCategoryPlaylists)
* categoryId 为空 → getRcmPlayList(order=hot);否则按 `<id>-<digest>` 走 getTagPlayList。
*/
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const tagId = String(categoryId ?? '').trim()
const pageNo = Math.max(1, Number(page) || 1)
const res = await songList.getList('hot', tagId || undefined, pageNo)
let list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
const requested = Number(limit) || 0
const serverLimit = Number(res?.limit ?? 0) || 0
if (requested > 0 && list.length > requested) list = list.slice(0, requested)
return {
source: 'kw' as const,
list,
page: pageNo,
limit: (requested > 0 && serverLimit > 0 ? Math.min(requested, serverLimit) : serverLimit) || list.length,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 推荐歌单(上游 GetRecommendedPlaylists;复用 getList(order=hot)) */
export const getRecommended = async(page = 1, limit = 30) => {
const pageNo = Math.max(1, Number(page) || 1)
const res = await songList.getList('hot', undefined, pageNo)
let list = (res?.list ?? []).map(toSummary).filter((item: any) => item.id)
const requested = Number(limit) || 0
const serverLimit = Number(res?.limit ?? 0) || 0
if (requested > 0 && list.length > requested) list = list.slice(0, requested)
return {
source: 'kw' as const,
list,
page: pageNo,
limit: (requested > 0 && serverLimit > 0 ? Math.min(requested, serverLimit) : serverLimit) || list.length,
total: Number(res?.total ?? 0) || list.length,
}
}
/** 上游 ParsePlaylist:playlist_detail/<id>、m.kuwo.cn/h5app/playlist/<id>(含 ?/# 尾串) */
export const parsePlaylistLink = async(link: string) => {
const match = String(link ?? '').match(/playlist_detail\/(\d+)/) ||
String(link ?? '').match(/\/playlist\/(\d+)/) ||
String(link ?? '').match(/playlistId=(\d+)/)
if (!match) throw new Error('无效的酷我歌单链接')
return getPlaylistDetail(match[1]!, 1)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
parsePlaylistLink,
}
+133 -31
View File
@@ -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 { filterMusicInfoItem } from './singer.ts'
import { createMusicuFetch } from './utils/index.ts' import { createMusicuFetch } from './utils/index.ts'
export default { const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
/** const ALBUM_BATCH = 100
* 通过专辑 mid 获取专辑详情与曲目列表
* @param {*} id 专辑 mid(搜索结果中的 albumId) export const filterSongList = (raw: any[]) =>
* @param {*} page (raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
* @param {*} limit
*/ const albumCover = (mid: string) => `https://y.gtimg.cn/music/photo_new/T002R500x500M000${mid}.jpg`
async getAlbumDetail(id, page = 1, limit = 100) { 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({ const body = await createMusicuFetch({
req: { req: {
module: 'music.musichallAlbum.AlbumSongList', module: 'music.musichallAlbum.AlbumSongList',
method: 'GetAlbumSongList', method: 'GetAlbumSongList',
// 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝 param: { albumMid, begin, num: ALBUM_BATCH, order: 2 },
param: { albumMid: id, begin: (page - 1) * limit, num: limit },
}, },
}) })
if (body.req.code != 0) throw new Error('get album song list faild.') 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 ?? [] * 通过专辑 mid 获取专辑详情与曲目列表
// GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息, * @param {*} id 专辑 mid(搜索结果中的 albumId)
// 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则 * @param {*} page
const first = songList[0]?.songInfo * @param {*} limit
const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))] */
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 { return {
source: 'tx', id: mid,
info: { name: String(item?.albumName ?? '').trim(),
name: first?.album?.name ?? null, cover: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${mid}.jpg`,
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`, trackCount: Number(item?.song_count ?? item?.songCount ?? 0) || 0,
desc: null, creator: String(item?.singerName ?? '').trim(),
author: authors.join('、') || null, description: '',
}, link: albumLink(mid),
list: this.filterSongList(songList), type: 'album',
page, albumId: String(item?.albumID ?? ''),
limit, publishTime: String(item?.publicTime ?? ''),
total: data.totalNum, source: 'tx' as const,
} }
}, }).filter(Boolean)
filterSongList(raw) { const total = Number(body.data.album.totalnum ?? body.data.album.total ?? list.length) || list.length
return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo)) 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,
} }
+6
View File
@@ -7,6 +7,9 @@ import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.ts' import singer from './singer.ts'
import album from './album.ts' import album from './album.ts'
import playlist from './playlist.ts'
import parse from './parse.ts'
import userPlaylist from './userPlaylist.ts'
const tx = { const tx = {
leaderboard, leaderboard,
@@ -16,6 +19,9 @@ const tx = {
comment, comment,
singer, singer,
album, album,
playlist,
parse,
userPlaylist,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return txApi.getMusicUrl(songInfo, type) return txApi.getMusicUrl(songInfo, type)
+110
View File
@@ -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 }
+272
View File
@@ -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,
}
+232
View File
@@ -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 }
+152
View File
@@ -0,0 +1,152 @@
// 网易云专辑(上游 music-lib/netease/album.go + netease.go 的 fetchAlbumDetail 移植)
// 搜索:cloudsearch pc type=10(eapi 通道,与现有 songList.search 同一通路)
// 详情/曲目:weapi/v1/album/<id>(匿名可用;登录 cookie 可选)
// 链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
import { httpFetch } from '../request.ts'
import { weapi } from './utils/crypto.ts'
import { formatPlayTime, sizeFormate } from '../index.ts'
import { formatSingerName } from '../utils.ts'
import { getWyCookie } from '../auth/store.ts'
import { eapiRequest } from './utils/index.ts'
import { parseNeteaseLink } from './link.ts'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
const albumLink = (id: string) => `https://music.163.com/#/album?id=${id}`
/** 网易云歌曲条目 → MusicServer Song 形状(与 wy/songList.filterListDetail 对齐) */
export const convertSong = (item: any) => {
const types: Array<{ type: string, size: string | null }> = []
const _types: Record<string, { size: string | null }> = {}
const push = (type: string, sizeRaw: any) => {
const size = typeof sizeRaw === 'number' && sizeRaw > 0 ? sizeFormate(sizeRaw) : null
if (!size) return
types.push({ type, size })
_types[type] = { size }
}
push('128k', item?.l?.size)
push('320k', item?.h?.size)
push('flac', item?.sq?.size)
push('flac24bit', item?.hr?.size)
return {
singer: formatSingerName(item?.ar, 'name'),
name: item?.name ?? '',
albumName: item?.al?.name ?? '',
albumId: item?.al?.id ?? item?.album?.id,
source: 'wy' as const,
interval: formatPlayTime((item?.dt ?? 0) / 1000),
songmid: item?.id,
img: item?.al?.picUrl ?? item?.album?.picUrl ?? '',
lrc: null,
otherSource: null,
types,
_types,
typeUrl: {},
}
}
/** 专辑详情 + 曲目(上游 fetchAlbumDetail:weapi/v1/album/<id>) */
export const fetchAlbumDetail = async(albumId: string) => {
const albumID = String(albumId ?? '').trim()
if (!albumID) throw new Error('wy 专辑 id 为空')
const cookie = getWyCookie()
const { statusCode, body } = await httpFetch(`https://music.163.com/weapi/v1/album/${encodeURIComponent(albumID)}`, {
method: 'post',
headers: {
'User-Agent': UA,
Referer: 'https://music.163.com/',
origin: 'https://music.163.com',
...(cookie ? { cookie } : {}),
},
form: weapi({ csrf_token: '' }),
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.code != 200) {
throw new Error(`wy 专辑详情获取失败 (code ${body?.code ?? statusCode})`)
}
const album = body.album ?? {}
const artistName = album.artist?.name
|| (album.artists ?? []).map((a: any) => a.name).filter(Boolean).join('、')
const songs = (body.songs ?? []).map(convertSong)
return {
source: 'wy' as const,
info: {
name: album.name ?? '',
img: album.picUrl ?? '',
desc: album.description || album.briefDesc || '',
author: artistName ?? '',
albumId: String(album.id ?? albumID),
publishTime: album.publishTime ?? null,
company: album.company ?? '',
},
list: songs,
total: Number(album.size ?? 0) || songs.length,
}
}
/** 专辑详情(分页切片,对齐 spec { source, info, list, page, limit, total }) */
export const getAlbumDetail = async(id: string, page = 1, limit = 50) => {
const detail = await fetchAlbumDetail(id)
const start = Math.max(0, (page - 1) * limit)
return {
source: 'wy' as const,
info: detail.info,
list: detail.list.slice(start, start + limit),
page,
limit,
total: detail.total || detail.list.length,
}
}
/** 专辑曲目全量(上游 GetAlbumSongs) */
export const getAlbumSongs = async(id: string) => (await fetchAlbumDetail(id)).list
/** 专辑搜索(上游 SearchAlbum:cloudSearch type=10) */
export const searchAlbum = async(keyword: string, page = 1, limit = 10) => {
const req = eapiRequest('/api/cloudsearch/pc', {
s: String(keyword ?? ''),
type: 10,
limit,
total: page === 1,
offset: limit * (page - 1),
})
const { statusCode, body } = await req.promise
if (statusCode !== 200 || body?.code != 200) throw new Error(`wy 专辑搜索失败 (code ${body?.code ?? statusCode})`)
const albums: any[] = body?.result?.albums ?? []
const list = albums.map(item => {
const id = String(item?.id ?? '').trim()
if (!id) return null
const artistName = item?.artist?.name
|| (item?.artists ?? []).map((a: any) => a?.name).filter(Boolean).join('、')
return {
id,
name: item?.name ?? '',
cover: item?.picUrl ?? '',
trackCount: Number(item?.size ?? 0) || 0,
creator: artistName ?? '',
description: item?.description || item?.briefDesc || '',
link: albumLink(id),
type: 'album',
company: item?.company ?? '',
publishTime: item?.publishTime ?? null,
source: 'wy' as const,
}
}).filter(Boolean)
const total = Number(body?.result?.albumCount ?? list.length) || list.length
return { list, page, limit, total, source: 'wy' as const }
}
/** 专辑链接解析(上游 ParseAlbum) */
export const parseAlbumLink = async(link: string) => {
const raw = String(link ?? '').trim()
// 裸数字 id 兜底(上游 ParseAlbum 只接受链接)
if (/^\d+$/.test(raw)) return getAlbumDetail(raw)
const { kind, id } = parseNeteaseLink(raw)
if (kind !== 'album') throw new Error('无效的网易云专辑链接')
return getAlbumDetail(id)
}
export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink, fetchAlbumDetail }
+8
View File
@@ -7,6 +7,10 @@ import songList from './songList.ts'
import hotSearch from './hotSearch.ts' import hotSearch from './hotSearch.ts'
import comment from './comment.ts' import comment from './comment.ts'
import singer from './singer.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 wy = { const wy = {
leaderboard, leaderboard,
@@ -15,6 +19,10 @@ const wy = {
hotSearch, hotSearch,
comment, comment,
singer, singer,
album,
playlist,
parse,
userPlaylist,
getMusicUrl(songInfo, type) { getMusicUrl(songInfo, type) {
return wyApi.getMusicUrl(songInfo, type) return wyApi.getMusicUrl(songInfo, type)
}, },
+78
View File
@@ -0,0 +1,78 @@
// 网易云链接解析(上游 music-lib/netease/netease.go 的 parseNeteaseLink 1:1 移植)
// 支持:
// 歌曲 https://music.163.com/#/song?id=29732995 /song?id= / /song/29732995
// 专辑 https://music.163.com/#/album?id=32311 /album?id= / /album/32311
// 歌单 https://music.163.com/#/playlist?id=7507256008 /playlist?id= / /playlist/<id>/<n>/...
// 裸数字 id 由各模块自行兜底(上游 Parse 不接受裸 id)
export type NeteaseLinkKind = 'song' | 'album' | 'playlist'
const isDigits = (value: string): boolean => value !== '' && /^\d+$/.test(value)
const BASE = 'https://music.163.com'
/** 上游 parseNeteaseLinkCandidate(JS 需给相对候选补 base;Go 的 url.Parse 直接接受相对路径) */
const parseCandidate = (candidate: string): { kind: NeteaseLinkKind | '', id: string } => {
let parsed: URL
try {
parsed = new URL(candidate, BASE)
} catch {
return { kind: '', id: '' }
}
let kind: NeteaseLinkKind | '' = ''
const segments = parsed.pathname.toLowerCase().split('/').filter(Boolean)
for (const segment of segments) {
if (segment === 'song' || segment === 'album' || segment === 'playlist') kind = segment
}
let id = parsed.searchParams.get('id') ?? ''
if (!isDigits(id)) id = ''
if (!kind && segments.length >= 2) {
const last = segments[segments.length - 1]!
const prev = segments[segments.length - 2]!
if (isDigits(last) && (prev === 'song' || prev === 'album' || prev === 'playlist')) {
kind = prev as NeteaseLinkKind
id = last
}
}
if (!kind || !id) return { kind: '', id: '' }
return { kind, id }
}
/** 上游 parseNeteaseLink:依次尝试原链接、path?query、hash 片段 */
export const parseNeteaseLink = (link: string): { kind: NeteaseLinkKind, id: string } => {
const raw = String(link ?? '').trim()
const candidates: string[] = [raw]
const parsed = (() => {
try {
return new URL(raw, BASE)
} catch {
return null
}
})()
if (parsed) {
if (parsed.pathname && parsed.pathname !== '/') {
candidates.push(parsed.pathname + parsed.search)
}
const fragment = parsed.hash.replace(/^#!?/, '').trim()
if (fragment) candidates.push(fragment)
}
for (const candidate of candidates) {
const { kind, id } = parseCandidate(candidate)
if (kind && id) return { kind, id }
}
throw new Error('无效的网易云链接')
}
/** 裸 id 兜底:非 URL 且全数字时视为对应类型的 id */
export const bareId = (link: string): string => {
const raw = String(link ?? '').trim()
return /^\d+$/.test(raw) ? raw : ''
}
export default { parseNeteaseLink, bareId }
+80
View File
@@ -0,0 +1,80 @@
// 网易云链接解析(上游 music-lib/netease/song.go 的 Parse 移植)
// 歌曲链接形态:
// https://music.163.com/#/song?id=29732995
// https://music.163.com/song?id=29732995
// https://music.163.com/song/29732995
// 裸数字 id 也接受(补充便利;上游仅接受链接)
// 直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
// 歌单/专辑链接抛清晰错误,提示使用对应 parsePlaylist/parseAlbum。
import { httpFetch } from '../request.ts'
import { weapi } from './utils/crypto.ts'
import { getWyCookie } from '../auth/store.ts'
import wyApi from '../apis/wy.ts'
import { convertSong, parseAlbumLink } from './album.ts'
import { parsePlaylistLink } from './playlist.ts'
import { parseNeteaseLink } from './link.ts'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
/** 上游 fetchSongsBatch 的单曲版本(weapi/v3/song/detail) */
const fetchSongById = async(songId: string) => {
const cookie = getWyCookie()
const { statusCode, body } = await httpFetch('https://music.163.com/weapi/v3/song/detail', {
method: 'post',
headers: {
'User-Agent': UA,
Referer: 'https://music.163.com/',
origin: 'https://music.163.com',
...(cookie ? { cookie } : {}),
},
form: weapi({
c: `[{"id":${songId}}]`,
ids: `[${songId}]`,
}),
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.code != 200) {
throw new Error(`wy 歌曲详情获取失败 (code ${body?.code ?? statusCode})`)
}
const item = body?.songs?.[0]
if (!item?.id) throw new Error('netease song not found')
return convertSong(item)
}
/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */
export const parseSong = async(link: string) => {
const raw = String(link ?? '').trim()
if (!raw) throw new Error('无效的网易云歌曲链接')
let songId = ''
if (/^\d+$/.test(raw)) {
songId = raw
} else {
const parsed = (() => {
try {
return parseNeteaseLink(raw)
} catch {
throw new Error('无效的网易云歌曲链接')
}
})()
if (parsed.kind === 'playlist') throw new Error('检测到网易云歌单链接,请使用 parsePlaylist')
if (parsed.kind === 'album') throw new Error('检测到网易云专辑链接,请使用 parseAlbum')
if (parsed.kind !== 'song') throw new Error('无效的网易云歌曲链接')
songId = parsed.id
}
const song: any = await fetchSongById(songId)
try {
const hit = await wyApi.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 }
+168
View File
@@ -0,0 +1,168 @@
// 网易云歌单(上游 music-lib/netease/playlist.go 移植)
// 搜索/分类:复用现有 wy/songList(search=cloudsearch type1000、getTag=catalogue、getList=playlist/list)
// 推荐:weapi/personalized/playlist(上游 GetRecommendedPlaylists,匿名可用)
// 详情/曲目:复用 songList.getListDetail(linuxapi forward),按 page/limit 切片
// 链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id(上游 parseNeteaseLink)
import { httpFetch } from '../request.ts'
import { weapi } from './utils/crypto.ts'
import { formatPlayCount } from '../index.ts'
import { getWyCookie } from '../auth/store.ts'
import songList from './songList.ts'
import { parseNeteaseLink } from './link.ts'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
// catalogue/list 接口固定页大小(songList.limit_list = 30,运行时读取以避免模块环上的 TDZ)
const apiLimit = () => Number(songList.limit_list) || 30
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
/** 歌单摘要:兼容既有 songList 字段并补 spec 形状(id/name/cover/trackCount/creator/...) */
const toSummary = (item: any, categoryId?: string) => {
const id = String(item?.id ?? '').trim()
if (!id) return null
const trackCount = Number(item?.trackCount ?? item?.total ?? 0) || 0
const playCount = Number(item?.playCount ?? 0) || 0
return {
...item,
id,
name: item?.name ?? '',
cover: item?.cover ?? item?.img ?? '',
trackCount,
playCount,
play_count: item?.play_count ?? formatPlayCount(playCount),
creator: item?.creator ?? item?.author ?? '',
description: item?.description ?? item?.desc ?? '',
link: playlistLink(id),
source: 'wy' as const,
...(categoryId ? { categoryId } : {}),
}
}
/** 歌单搜索(上游 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(item => toSummary(item)).filter(Boolean)
return { list, page, limit, total: result?.total ?? list.length, source: 'wy' as const }
}
/** 歌单详情(上游 fetchPlaylistDetail;复用 songList.getListDetail,按 page/limit 切片) */
export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => {
const detail = await songList.getListDetail(String(id ?? ''), 1)
const start = Math.max(0, (page - 1) * limit)
return {
source: 'wy' 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) => (await songList.getListDetail(String(id ?? ''), 1)).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: 'wy' as const, list }
}
/** 分类歌单(上游 GetCategoryPlaylists;接口每页 30 条,按入参 limit 重新切片) */
export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => {
const cat = String(categoryId ?? '').trim() || '全部'
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 result = await songList.getList('hot', cat, apiPage)
total = Number(result?.total ?? total) || total
const batch = result?.list ?? []
items.push(...batch)
if (batch.length < apiLimit()) break
}
const list = items.slice(start, start + limit).map(item => toSummary(item, cat)).filter(Boolean)
return { list, page, limit, total: total || items.length, source: 'wy' as const }
}
/** 推荐歌单(上游 GetRecommendedPlaylists:weapi/personalized/playlist) */
export const getRecommended = async(page = 1, limit = 20) => {
const cookie = getWyCookie()
const { statusCode, body } = await httpFetch('https://music.163.com/weapi/personalized/playlist', {
method: 'post',
headers: {
'User-Agent': UA,
Referer: 'https://music.163.com/',
origin: 'https://music.163.com',
...(cookie ? { cookie } : {}),
},
form: weapi({ limit: 30, total: true, n: 1000 }),
timeout: 15000,
}).promise
if (statusCode !== 200 || body?.code != 200) {
throw new Error(`wy 推荐歌单获取失败 (code ${body?.code ?? statusCode})`)
}
const rawList: any[] = body?.result ?? []
const list = rawList.map(item => {
const id = String(item?.id ?? '').trim()
if (!id) return null
const playCount = Number(item?.playCount ?? 0) || 0
return {
id,
name: item?.name ?? '',
cover: item?.picUrl ?? '',
trackCount: Number(item?.trackCount ?? 0) || 0,
playCount,
play_count: formatPlayCount(playCount),
creator: item?.copywriter || '网易云推荐',
description: item?.copywriter ?? '',
link: playlistLink(id),
alg: item?.alg ?? '',
source: 'wy' 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: 'wy' as const }
}
export const getRecommendedPlaylists = getRecommended
/** 歌单链接解析(上游 ParsePlaylist) */
export const parsePlaylistLink = async(link: string) => {
const raw = String(link ?? '').trim()
if (!raw) throw new Error('无效的网易云歌单链接')
if (/^\d+$/.test(raw)) return getPlaylistDetail(raw)
const { kind, id } = parseNeteaseLink(raw)
if (kind !== 'playlist') throw new Error('无效的网易云歌单链接')
return getPlaylistDetail(id)
}
export default {
searchPlaylist,
getPlaylistDetail,
getPlaylistSongs,
getCategories,
getCategoryPlaylists,
getRecommended,
getRecommendedPlaylists,
parsePlaylistLink,
}
+95
View File
@@ -0,0 +1,95 @@
// 网易云个人歌单(上游 music-lib/netease/user_playlist.go 移植)
// 需登录 cookie MUSIC_U(sdk/auth/store.ts 的 getCredentials('wy').cookie / musicU):
// 未登录抛出清晰错误,不返回空列表
// 1) weapi/nuser/account/get → 当前用户 userId
// 2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
import { httpFetch } from '../request.ts'
import { weapi } from './utils/crypto.ts'
import { formatPlayCount } from '../index.ts'
import { getCredentials, getWyCookie } from '../auth/store.ts'
import type { WyCredentials } from '../auth/store.ts'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36'
const playlistLink = (id: string) => `https://music.163.com/#/playlist?id=${id}`
const readCookie = (): string => {
const cred = getCredentials('wy') as WyCredentials | null
const musicU = String(cred?.musicU ?? '').trim()
if (musicU) return `MUSIC_U=${musicU}`
return String(cred?.cookie ?? getWyCookie()).trim()
}
const weapiPost = async(url: string, data: object, cookie: string) => {
const { statusCode, body } = await httpFetch(url, {
method: 'post',
headers: {
'User-Agent': UA,
Referer: 'https://music.163.com/',
origin: 'https://music.163.com',
'Content-Type': 'application/x-www-form-urlencoded',
Cookie: cookie,
},
form: weapi(data),
timeout: 15000,
}).promise
if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error('wy 个人歌单接口请求失败')
return body
}
/**
* 个人歌单列表(上游 GetUserPlaylists)
* 未登录(无 cookie/MUSIC_U)时抛出清晰错误,不返回空列表
*/
export const getList = async(page = 1, limit = 30) => {
const cookie = readCookie()
if (!cookie) throw new Error('wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云')
const safePage = Math.max(1, Number(page) || 1)
let safeLimit = Number(limit) || 30
if (safeLimit <= 0) safeLimit = 30
if (safeLimit > 100) safeLimit = 100
const account = await weapiPost('https://music.163.com/weapi/nuser/account/get', { csrf_token: '' }, cookie)
if (account.code !== 200 || !account?.profile?.userId) {
throw new Error(`wy 登录态无效或已过期 (code ${account?.code ?? 'unknown'})`)
}
const uid = account.profile.userId
const nickname = account.profile.nickname ?? ''
const body = await weapiPost('https://music.163.com/weapi/user/playlist', {
uid,
limit: safeLimit,
offset: (safePage - 1) * safeLimit,
includeVideo: true,
csrf_token: '',
}, cookie)
if (body.code !== 200) throw new Error(`wy 个人歌单获取失败 (code ${body.code})`)
const list = (body.playlist ?? []).map(item => {
const id = String(item?.id ?? '').trim()
if (!id) return null
const trackCount = Number(item?.trackCount ?? 0) || 0
const playCount = Number(item?.playCount ?? 0) || 0
const creator = String(item?.creator?.nickname ?? '').trim() || nickname
return {
id,
name: item?.name ?? '',
cover: item?.coverImgUrl ?? '',
trackCount,
playCount,
play_count: formatPlayCount(playCount),
creator,
description: item?.description ?? '',
subscribed: Boolean(item?.subscribed),
link: playlistLink(id),
source: 'wy' as const,
}
}).filter(Boolean)
return { list, page: safePage, limit: safeLimit, total: list.length, source: 'wy' as const }
}
export const getUserPlaylists = getList
export default { getList, getUserPlaylists }