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
+2196
-11
No files matched your search
+8
-2
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('无效的酷狗专辑链接')
|
||||||
|
},
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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 }
|
||||||
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
+111
-9
@@ -1,14 +1,62 @@
|
|||||||
|
// 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
|
||||||
|
|
||||||
|
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',
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
* 通过专辑 mid 获取专辑详情与曲目列表
|
* 通过专辑 mid 获取专辑详情与曲目列表
|
||||||
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
* @param {*} id 专辑 mid(搜索结果中的 albumId)
|
||||||
* @param {*} page
|
* @param {*} page
|
||||||
* @param {*} limit
|
* @param {*} limit
|
||||||
*/
|
*/
|
||||||
async getAlbumDetail(id, page = 1, limit = 100) {
|
export const getAlbumDetail = async(id: string, page = 1, limit = 100) => {
|
||||||
const body = await createMusicuFetch({
|
const body = await createMusicuFetch({
|
||||||
req: {
|
req: {
|
||||||
module: 'music.musichallAlbum.AlbumSongList',
|
module: 'music.musichallAlbum.AlbumSongList',
|
||||||
@@ -29,17 +77,71 @@ export default {
|
|||||||
source: 'tx',
|
source: 'tx',
|
||||||
info: {
|
info: {
|
||||||
name: first?.album?.name ?? null,
|
name: first?.album?.name ?? null,
|
||||||
img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`,
|
img: albumCover(String(id)),
|
||||||
desc: null,
|
desc: null,
|
||||||
author: authors.join('、') || null,
|
author: authors.join('、') || null,
|
||||||
},
|
},
|
||||||
list: this.filterSongList(songList),
|
list: filterSongList(songList),
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
total: data.totalNum,
|
total: data.totalNum,
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
filterSongList(raw) {
|
|
||||||
return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo))
|
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 {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}).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 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
@@ -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 }
|
||||||
+152
@@ -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 }
|
||||||
@@ -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)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
Reference in new issue
Block a user