From 68e071b166856570b4cfa3b6db18dd70464545c3 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:26:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdk):=20kg/kw/tx/wy=20=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E4=B8=93=E8=BE=91=E3=80=81=E6=AD=8C=E5=8D=95=E4=B8=8E=20parse?= =?UTF-8?q?=20=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 四个主流平台从「搜索 + 取流」补到能力对齐,新增 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 规范化, 与新增平台的注册表改动分开,出问题时便于单独回滚。 --- sdk/apis/kw.ts | 10 +- sdk/kg/album.ts | 63 ++++++++++ sdk/kg/index.ts | 4 + sdk/kg/parse.ts | 111 +++++++++++++++++ sdk/kg/playlist.ts | 237 +++++++++++++++++++++++++++++++++++ sdk/kw/album.ts | 133 ++++++++++++++++++++ sdk/kw/index.ts | 4 + sdk/kw/parse.ts | 107 ++++++++++++++++ sdk/kw/playlist.ts | 217 ++++++++++++++++++++++++++++++++ sdk/tx/album.ts | 164 ++++++++++++++++++++----- sdk/tx/index.ts | 6 + sdk/tx/parse.ts | 110 +++++++++++++++++ sdk/tx/playlist.ts | 272 +++++++++++++++++++++++++++++++++++++++++ sdk/tx/userPlaylist.ts | 232 +++++++++++++++++++++++++++++++++++ sdk/wy/album.ts | 152 +++++++++++++++++++++++ sdk/wy/index.ts | 8 ++ sdk/wy/link.ts | 78 ++++++++++++ sdk/wy/parse.ts | 80 ++++++++++++ sdk/wy/playlist.ts | 168 +++++++++++++++++++++++++ sdk/wy/userPlaylist.ts | 95 ++++++++++++++ 20 files changed, 2218 insertions(+), 33 deletions(-) create mode 100644 sdk/kg/parse.ts create mode 100644 sdk/kg/playlist.ts create mode 100644 sdk/kw/parse.ts create mode 100644 sdk/kw/playlist.ts create mode 100644 sdk/tx/parse.ts create mode 100644 sdk/tx/playlist.ts create mode 100644 sdk/tx/userPlaylist.ts create mode 100644 sdk/wy/album.ts create mode 100644 sdk/wy/link.ts create mode 100644 sdk/wy/parse.ts create mode 100644 sdk/wy/playlist.ts create mode 100644 sdk/wy/userPlaylist.ts diff --git a/sdk/apis/kw.ts b/sdk/apis/kw.ts index bcfebac..01380ed 100644 --- a/sdk/apis/kw.ts +++ b/sdk/apis/kw.ts @@ -15,6 +15,7 @@ // 不匹配视为未命中、沿降级链换档重试; // - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合, // 本组合已验证为真音频。 +// - 返回 url 的 query 用 `$` 顶替 `=`,统一还原后对外返回(见 normalizeUrl)。 // 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头) // 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发), // 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法, @@ -48,6 +49,11 @@ const fallbackChain: Record = { // 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播 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) 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 if (statusCode != 200 || !data?.url) 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) } @@ -156,7 +162,7 @@ const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => { return Promise.reject(new Error(msg || '获取失败')) } // 服务器无视 br,匿名实际只回 128k,如实上报 - return { type: '128k', url: body.data.url } + return { type: '128k', url: normalizeUrl(body.data.url) } }) return requestObj } diff --git a/sdk/kg/album.ts b/sdk/kg/album.ts index b64bd62..cc0886c 100644 --- a/sdk/kg/album.ts +++ b/sdk/kg/album.ts @@ -1,5 +1,8 @@ import { getMusicInfosByList } from './musicInfo.ts' import { createHttpFetch } from './util.ts' +import { httpFetch } from '../request.ts' + +const albumCover = (url: any): string => String(url ?? '').replace('{size}', '240') 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/.html、album/.html、albumid= */ + 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('无效的酷狗专辑链接') + }, } diff --git a/sdk/kg/index.ts b/sdk/kg/index.ts index d49af85..d7289ac 100644 --- a/sdk/kg/index.ts +++ b/sdk/kg/index.ts @@ -8,6 +8,8 @@ import hotSearch from './hotSearch.ts' import comment from './comment.ts' import singer from './singer.ts' import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' import { registerDfid } from '../auth/kg.ts' const kg = { @@ -22,6 +24,8 @@ const kg = { comment, singer, album, + playlist, + parse, getMusicUrl(songInfo, type) { return kgApi.getMusicUrl(songInfo, type) }, diff --git a/sdk/kg/parse.ts b/sdk/kg/parse.ts new file mode 100644 index 0000000..b733241 --- /dev/null +++ b/sdk/kg/parse.ts @@ -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 } diff --git a/sdk/kg/playlist.ts b/sdk/kg/playlist.ts new file mode 100644 index 0000000..c837f04 --- /dev/null +++ b/sdk/kg/playlist.ts @@ -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 形如 `:`) */ +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/.html → special 详情 + * /songlist// → 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, +} diff --git a/sdk/kw/album.ts b/sdk/kw/album.ts index 3966d70..c11647b 100644 --- a/sdk/kw/album.ts +++ b/sdk/kw/album.ts @@ -3,6 +3,15 @@ import { decodeName } from '../index.ts' import { formatSinger, objStr2JSON } from './util.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 export default { 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() + 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/、album/、albumid= */ + 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) { // 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) => { diff --git a/sdk/kw/index.ts b/sdk/kw/index.ts index 4344b4c..ed486db 100644 --- a/sdk/kw/index.ts +++ b/sdk/kw/index.ts @@ -9,6 +9,8 @@ import songList from './songList.ts' import hotSearch from './hotSearch.ts' import comment from './comment.ts' import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' import type { HttpRequestObject } from '../types.ts' const kw = { @@ -17,6 +19,8 @@ const kw = { _musicPicRequestObj: null as HttpRequestObject | null, _musicPicPromiseCancelFn: null, album, + playlist, + parse, // context: null, diff --git a/sdk/kw/parse.ts b/sdk/kw/parse.ts new file mode 100644 index 0000000..49e6c19 --- /dev/null +++ b/sdk/kw/parse.ts @@ -0,0 +1,107 @@ +// 酷我链接解析 +// 歌曲:https://www.kuwo.cn/play_detail/(含 ?/# 尾串)、m.kuwo.cn 的 musicId=、裸 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 => { + 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 } diff --git a/sdk/kw/playlist.ts b/sdk/kw/playlist.ts new file mode 100644 index 0000000..c971d20 --- /dev/null +++ b/sdk/kw/playlist.ts @@ -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() + 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 形如 `-`) */ +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);否则按 `-` 走 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/、m.kuwo.cn/h5app/playlist/(含 ?/# 尾串) */ +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, +} diff --git a/sdk/tx/album.ts b/sdk/tx/album.ts index fa92fe4..9fe8ae2 100644 --- a/sdk/tx/album.ts +++ b/sdk/tx/album.ts @@ -1,45 +1,147 @@ +// QQ音乐专辑(上游 music-lib/qq/album.go + qq.go 的 fetchAlbumDetail 移植) +// 搜索:c.y.qq.com soso search_for_qq_cp t=8(上游 SearchAlbum) +// 曲目:music.musichallAlbum.AlbumSongList GetAlbumSongList 按 100 一批翻页(上游 fetchAlbumDetail 的曲目段) +// 详情:GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息,作者由曲目歌手去重合成,封面走平台固定 URL 规则 +// 链接解析:albumDetail/、album/、albummid=(上游 ParseAlbum) +import { httpFetch } from '../request.ts' import { filterMusicInfoItem } from './singer.ts' import { createMusicuFetch } from './utils/index.ts' -export default { - /** - * 通过专辑 mid 获取专辑详情与曲目列表 - * @param {*} id 专辑 mid(搜索结果中的 albumId) - * @param {*} page - * @param {*} limit - */ - async getAlbumDetail(id, page = 1, limit = 100) { +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' +const ALBUM_BATCH = 100 + +export const filterSongList = (raw: any[]) => + (raw ?? []).map(item => filterMusicInfoItem(item.songInfo)) + +const albumCover = (mid: string) => `https://y.gtimg.cn/music/photo_new/T002R500x500M000${mid}.jpg` +const albumLink = (mid: string) => `https://y.qq.com/n/ryqq/albumDetail/${mid}` + +/** 专辑曲目全量(上游 fetchAlbumDetail 的曲目段:AlbumSongList 100 一批翻页) */ +export const fetchAlbumSongs = async(albumId: string) => { + const albumMid = String(albumId ?? '').trim() + if (!albumMid) throw new Error('tx 专辑 id 为空') + + const songs: any[] = [] + let totalNum = 0 + for (let begin = 0; ; begin += ALBUM_BATCH) { const body = await createMusicuFetch({ req: { module: 'music.musichallAlbum.AlbumSongList', method: 'GetAlbumSongList', - // 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝 - param: { albumMid: id, begin: (page - 1) * limit, num: limit }, + param: { albumMid, begin, num: ALBUM_BATCH, order: 2 }, }, }) if (body.req.code != 0) throw new Error('get album song list faild.') + const data = body.req.data ?? {} + const list = data.songList ?? [] + if (!totalNum) totalNum = Number(data.totalNum ?? 0) || 0 + if (!list.length) break + songs.push(...list) + if (list.length < ALBUM_BATCH) break + if (totalNum > 0 && begin + list.length >= totalNum) break + } + const meta: any = songs[0]?.songInfo ?? null + return { + raw: songs, + songs: filterSongList(songs), + total: totalNum || songs.length, + albumMid, + albumName: meta?.album?.name ?? null, + } +} - const data = body.req.data - const songList = data.songList ?? [] - // GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息, - // 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则 - const first = songList[0]?.songInfo - const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))] +/** + * 通过专辑 mid 获取专辑详情与曲目列表 + * @param {*} id 专辑 mid(搜索结果中的 albumId) + * @param {*} page + * @param {*} limit + */ +export const getAlbumDetail = async(id: string, page = 1, limit = 100) => { + const body = await createMusicuFetch({ + req: { + module: 'music.musichallAlbum.AlbumSongList', + method: 'GetAlbumSongList', + // 注意:参数名是驼峰 albumMid,下划线形态会被上游以 104400 拒绝 + param: { albumMid: id, begin: (page - 1) * limit, num: limit }, + }, + }) + if (body.req.code != 0) throw new Error('get album song list faild.') + + const data = body.req.data + const songList = data.songList ?? [] + // GetAlbumInfo 详情接口已停用:专辑名取自曲目自带的 album 信息, + // 作者由曲目歌手去重合成,封面用平台固定图片 URL 规则 + const first = songList[0]?.songInfo + const authors = [...new Set(songList.flatMap(item => item.songInfo?.singer?.map(s => s.name) ?? []))] + return { + source: 'tx', + info: { + name: first?.album?.name ?? null, + img: albumCover(String(id)), + desc: null, + author: authors.join('、') || null, + }, + list: filterSongList(songList), + page, + limit, + total: data.totalNum, + } +} + +export const getAlbumSongs = async(id: string) => (await fetchAlbumSongs(id)).songs + +/** 专辑搜索(上游 SearchAlbum:search_for_qq_cp t=8) */ +export const searchAlbum = async(keyword: string, page = 1, limit = 10) => { + const url = `http://c.y.qq.com/soso/fcgi-bin/search_for_qq_cp?format=json&p=${page}&n=${limit}&w=${encodeURIComponent(String(keyword ?? ''))}&t=8` + const { statusCode, body } = await httpFetch(url, { + headers: { 'User-Agent': UA, Referer: 'https://y.qq.com/portal/search.html' }, + timeout: 15000, + }).promise + if (statusCode !== 200 || !body?.data?.album) throw new Error('tx 专辑搜索失败') + + const rawList: any[] = body.data.album.list ?? [] + const list = rawList.map(item => { + const mid = String(item?.albumMID ?? '').trim() + if (!mid) return null return { - source: 'tx', - info: { - name: first?.album?.name ?? null, - img: `https://y.gtimg.cn/music/photo_new/T002R500x500M000${id}.jpg`, - desc: null, - author: authors.join('、') || null, - }, - list: this.filterSongList(songList), - page, - limit, - total: data.totalNum, + id: mid, + name: String(item?.albumName ?? '').trim(), + cover: `https://y.gtimg.cn/music/photo_new/T002R300x300M000${mid}.jpg`, + trackCount: Number(item?.song_count ?? item?.songCount ?? 0) || 0, + creator: String(item?.singerName ?? '').trim(), + description: '', + link: albumLink(mid), + type: 'album', + albumId: String(item?.albumID ?? ''), + publishTime: String(item?.publicTime ?? ''), + source: 'tx' as const, } - }, - filterSongList(raw) { - return (raw ?? []).map(item => filterMusicInfoItem(item.songInfo)) - }, + }).filter(Boolean) + const total = Number(body.data.album.totalnum ?? body.data.album.total ?? list.length) || list.length + return { list, page, limit, total, source: 'tx' as const } +} + +const ALBUM_PATTERNS = [ + /albumDetail\/([A-Za-z0-9]+)/, + /\/album\/([A-Za-z0-9]+)/, + /albummid=([A-Za-z0-9]+)/, +] + +/** 专辑链接解析(上游 ParseAlbum) */ +export const parseAlbumLink = async(link: string) => { + const raw = String(link ?? '') + for (const pattern of ALBUM_PATTERNS) { + const match = pattern.exec(raw) + if (match) return getAlbumDetail(match[1]!) + } + throw new Error('无效的QQ音乐专辑链接') +} + +export default { + getAlbumDetail, + getAlbumSongs, + searchAlbum, + parseAlbumLink, + fetchAlbumSongs, + filterSongList, } diff --git a/sdk/tx/index.ts b/sdk/tx/index.ts index 1024f71..e83da98 100644 --- a/sdk/tx/index.ts +++ b/sdk/tx/index.ts @@ -7,6 +7,9 @@ import hotSearch from './hotSearch.ts' import comment from './comment.ts' import singer from './singer.ts' import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import userPlaylist from './userPlaylist.ts' const tx = { leaderboard, @@ -16,6 +19,9 @@ const tx = { comment, singer, album, + playlist, + parse, + userPlaylist, getMusicUrl(songInfo, type) { return txApi.getMusicUrl(songInfo, type) diff --git a/sdk/tx/parse.ts b/sdk/tx/parse.ts new file mode 100644 index 0000000..f8f144e --- /dev/null +++ b/sdk/tx/parse.ts @@ -0,0 +1,110 @@ +// QQ音乐链接解析(上游 music-lib/qq/song.go 的 Parse 移植) +// 歌曲链接形态: +// https://y.qq.com/n/ryqq/songDetail/ +// https://y.qq.com/n/yqq/song/.html +// https://i.y.qq.com/v8/playsong.html?songid=<数字 id>(先反查 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 = {} + 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 } diff --git a/sdk/tx/playlist.ts b/sdk/tx/playlist.ts new file mode 100644 index 0000000..1a2a788 --- /dev/null +++ b/sdk/tx/playlist.ts @@ -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(['']) + 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(/
/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(/
/g, '\n'), + description: String(basic?.desc ?? '').replace(/
/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, +} diff --git a/sdk/tx/userPlaylist.ts b/sdk/tx/userPlaylist.ts new file mode 100644 index 0000000..1cd9f17 --- /dev/null +++ b/sdk/tx/userPlaylist.ts @@ -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 & { 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() + 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 } diff --git a/sdk/wy/album.ts b/sdk/wy/album.ts new file mode 100644 index 0000000..43d22a1 --- /dev/null +++ b/sdk/wy/album.ts @@ -0,0 +1,152 @@ +// 网易云专辑(上游 music-lib/netease/album.go + netease.go 的 fetchAlbumDetail 移植) +// 搜索:cloudsearch pc type=10(eapi 通道,与现有 songList.search 同一通路) +// 详情/曲目:weapi/v1/album/(匿名可用;登录 cookie 可选) +// 链接解析:#/album?id= / /album?id= / /album/(上游 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 = {} + 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/) */ +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 } diff --git a/sdk/wy/index.ts b/sdk/wy/index.ts index 7d0052c..51ced32 100644 --- a/sdk/wy/index.ts +++ b/sdk/wy/index.ts @@ -7,6 +7,10 @@ import songList from './songList.ts' import hotSearch from './hotSearch.ts' import comment from './comment.ts' import singer from './singer.ts' +import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import userPlaylist from './userPlaylist.ts' const wy = { leaderboard, @@ -15,6 +19,10 @@ const wy = { hotSearch, comment, singer, + album, + playlist, + parse, + userPlaylist, getMusicUrl(songInfo, type) { return wyApi.getMusicUrl(songInfo, type) }, diff --git a/sdk/wy/link.ts b/sdk/wy/link.ts new file mode 100644 index 0000000..19c992b --- /dev/null +++ b/sdk/wy/link.ts @@ -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 由各模块自行兜底(上游 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 } diff --git a/sdk/wy/parse.ts b/sdk/wy/parse.ts new file mode 100644 index 0000000..6276d37 --- /dev/null +++ b/sdk/wy/parse.ts @@ -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 } diff --git a/sdk/wy/playlist.ts b/sdk/wy/playlist.ts new file mode 100644 index 0000000..0116688 --- /dev/null +++ b/sdk/wy/playlist.ts @@ -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(上游 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(['']) + 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, +} diff --git a/sdk/wy/userPlaylist.ts b/sdk/wy/userPlaylist.ts new file mode 100644 index 0000000..9c9332b --- /dev/null +++ b/sdk/wy/userPlaylist.ts @@ -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 }