diff --git a/sdk/5sing/index.ts b/sdk/5sing/index.ts new file mode 100644 index 0000000..30f8d8c --- /dev/null +++ b/sdk/5sing/index.ts @@ -0,0 +1,50 @@ +// 5sing(5sing.kugou.com,酷狗旗下原创/翻唱音乐平台)平台模块 +// 上游 music-lib/fivesing 移植(平台 id 统一为 '5sing',上游 model.Source 为 "fivesing")。 +// 能力:搜索(原创 yc / 翻唱 fc / 伴奏 bz,统一种类字段 songType)/ 取流(明文直链,档位 sq→hq→lq)/ +// 歌词(newget.dynamicWords)/ 歌单(搜索、详情、链接解析)/ 单曲链接解析。 +// 不实现:专辑、封面(上游无 GetPic)、分类歌单/个人歌单(上游显式 not supported,保留抛错语义)、 +// 榜单/热搜/歌手/评论(上游均无)。 +// 取流入口在 sdk/apis/5sing.ts;songmid 约定 "|"(如 "932557|yc",与上游 ID 一致)。 +// 登录:上游 New(cookie) 的 cookie 会随请求发送(付费/受限内容可能受益); +// 本移植从 sdk/auth/store.ts 的 getCredentials('5sing') 读取 cookie(无则匿名,匿名实测可搜索/取流/歌词/歌单)。 + +import musicSearch from './musicSearch.ts' +import lyric from './lyric.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import fivesingApi from '../apis/5sing.ts' +import { buildSongLink, extractSongId, getStatus } from './util.ts' + +const fivesing = { + musicSearch, + + getMusicUrl(songInfo, type) { + return fivesingApi.getMusicUrl(songInfo, type) + }, + + getLyric(songInfo, ...rest) { + return lyric.getLyric(songInfo, ...rest) + }, + + playlist, + parse, + + getMusicDetailPageUrl(songInfo) { + if (songInfo?.link) return String(songInfo.link) + const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id, songInfo?.songType) + if (!songID || !songType) return 'http://5sing.kugou.com/' + return buildSongLink(songType, songID) + }, + + // 上游 GetUserPlaylists 显式返回 not supported,这里保留同样语义 + userPlaylist: { + async getList(_page?: number, _limit?: number): Promise { + throw new Error('user playlists not supported') + }, + }, +} + +/** 登录状态(凭据存在性判断;5sing 上游无扫码/账密登录,凭据只能手工写入 data/auth.json) */ +export const status = getStatus + +export default fivesing diff --git a/sdk/5sing/lyric.ts b/sdk/5sing/lyric.ts new file mode 100644 index 0000000..9e3b581 --- /dev/null +++ b/sdk/5sing/lyric.ts @@ -0,0 +1,38 @@ +// 5sing 歌词(上游 fivesing/lyric.go 的 GetLyrics 移植) +// 接口: GET http://mobileapi.5sing.kugou.com/song/newget?songid=&songtype= +// → data.dynamicWords 即 LRC 文本(实测为 "[00:31.76]..." 格式,逐字/滚动歌词同源)。 +// 上游 ID 约定:model.Song.ID = "|",Extra.songid/songtype 优先; +// 本移植从 songmid 的 "|" 拆分(路由层只透传 songmid),也接受 songInfo.songType 字段。 +// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric: '' }(5sing 无翻译歌词)。 + +import { isForeignSource, extractSongId } from './util.ts' +import { fetchDynamicLyric } from './musicInfo.ts' + +/** 非取消句柄版(测试/内部用):直接返回歌词文本 */ +export const getLyricText = async(songID: string, songType: string): Promise => { + const lyric = await fetchDynamicLyric(songID, songType) + if (!lyric) throw new Error('lyrics not found') + return lyric +} + +export const getLyric = (songInfo: any, ..._rest: any[]) => { + const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} } + let current: any = null + requestObj.cancelHttp = () => { + current?.cancelHttp?.() + current = null + } + + requestObj.promise = (async() => { + if (isForeignSource(songInfo)) throw new Error('source mismatch') + const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType) + if (!songID) throw new Error('invalid id') + const lyric = await fetchDynamicLyric(songID, songType, (request) => { current = request }) + if (!lyric) throw new Error('lyrics not found') + return { lyric, tlyric: '' } + })() + + return requestObj +} + +export default { getLyric, getLyricText } diff --git a/sdk/5sing/musicInfo.ts b/sdk/5sing/musicInfo.ts new file mode 100644 index 0000000..944e678 --- /dev/null +++ b/sdk/5sing/musicInfo.ts @@ -0,0 +1,114 @@ +// 5sing 歌曲信息 / 取流底层(上游 fivesing/fivesing.go 的 fetchSongInfo / fetchAudioLink 移植) +// +// 链路(实测 2026-10-02 全部可用,无需签名/base64 参数——上游 fivesing 包内没有 _sign/base64 逻辑, +// 仅 playlist 搜索的封面等是 CDN 地址;取流为明文 URL 直出): +// 取流: GET {MOBILE_API}/song/getSongUrl?songid=&songtype= → data.{sq,hq,lq}url(_backup),code 必须 1000 +// 详情: GET {MOBILE_API}/song/newget?songid=&songtype= → data.SN / data.user.NN / data.user.I / data.dynamicWords +// 档位字段:squrl(无损/高码)→ hqurl(高)→ lqurl(低),每档有 _backup 备用地址(上游 getFirstValid 取首个非空)。 +// 上游固定 sq→hq→lq;本移植按请求档位微调(请求 64k/lq 时倒序),返回实际命中档位。 + +import { MOBILE_API, fetch5singJson, getFirstValid, kindName, estimateDuration, buildSongLink } from './util.ts' + +export type AudioHit = { type: string, url: string } + +const QUALITY_TIERS: Array<'sq' | 'hq' | 'lq'> = ['sq', 'hq', 'lq'] + +/** data 里某档位的可用地址(主地址 → backup,上游 getFirstValid 语义) */ +const tierUrl = (data: any, tier: string): string => + getFirstValid(data?.[`${tier}url`], data?.[`${tier}url_backup`]) + +/** + * 上游 fetchAudioLink:仅获取音频链接。 + * register 用于把当前 httpFetch 对象交给调用方(cancelHttp)。 + * preferLow:请求 64k/lq 时从低档位开始找(上游无该意识,移植补的最小档位选择,默认仍 sq 最高)。 + */ +export const fetchAudioLink = async( + songID: string, + songType: string, + register?: (request: any) => void, + preferLow = false, +): Promise => { + const url = `${MOBILE_API}/song/getSongUrl?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}` + const { statusCode, body } = await fetch5singJson(url, { register }) + if (statusCode != 200 || !body || typeof body !== 'object') { + throw new Error(`5sing 取流失败(HTTP ${statusCode})`) + } + if (Number(body.code) !== 1000) { + // 实测 songtype 缺失/错误时 code=28 message=歌曲不存在 + throw new Error(body.message ? `5sing 取流失败(${body.message})` : 'api returned error code') + } + const data = body.data ?? {} + const order = preferLow ? [...QUALITY_TIERS].reverse() : QUALITY_TIERS + for (const tier of order) { + const hit = tierUrl(data, tier) + if (hit && /^https?:\/\//i.test(hit)) return { type: tier, url: hit } + } + throw new Error('no valid download url found') +} + +/** 上游 fetchSongInfo / GetLyrics 使用的 newget 详情(返回 data 对象) */ +export const fetchNewGet = async( + songID: string, + songType: string, + register?: (request: any) => void, +): Promise => { + const url = `${MOBILE_API}/song/newget?songid=${encodeURIComponent(songID)}&songtype=${encodeURIComponent(songType)}` + const { statusCode, body } = await fetch5singJson(url, { register }) + if (statusCode != 200 || !body || typeof body !== 'object') { + throw new Error(`5sing 歌曲信息获取失败(HTTP ${statusCode})`) + } + return body.data ?? {} +} + +/** 上游 newget 歌词:data.dynamicWords(LRC 文本) */ +export const fetchDynamicLyric = async( + songID: string, + songType: string, + register?: (request: any) => void, +): Promise => { + const data = await fetchNewGet(songID, songType, register) + return String(data?.dynamicWords ?? '') +} + +/** + * 上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。 + * 取流失败即抛错(与上游 Parse 链路一致);newget 失败时保留兜底名 5sing__。 + */ +export const fetchSongInfo = async(songID: string, songType: string, register?: (request: any) => void) => { + const hit = await fetchAudioLink(songID, songType, register) + + let name = '' + let artist = '' + let cover = '' + try { + const data = await fetchNewGet(songID, songType, register) + name = String(data?.SN ?? '') + artist = String(data?.user?.NN ?? '') + cover = String(data?.user?.I ?? '') + } catch { + // 上游 newget 失败时继续(metaBody 为 nil 不阻断) + } + if (!name) name = `5sing_${songType}_${songID}` + + return { + songmid: `${songID}|${songType}`, + name, + singer: artist, + albumId: '', + albumName: '', + interval: 0, + img: cover || null, + lrc: null, + types: [], + _types: {}, + url: hit.url, + type: hit.type, + link: buildSongLink(songType, songID), + songType, + typeName: kindName(songType), + source: '5sing' as const, + } +} + +/** 时长估算的对外小工具(搜索结果用;上游 Search 的 Duration 公式) */ +export const songInterval = (songSize: number): number => estimateDuration(songSize) diff --git a/sdk/5sing/musicSearch.ts b/sdk/5sing/musicSearch.ts new file mode 100644 index 0000000..b24fa5e --- /dev/null +++ b/sdk/5sing/musicSearch.ts @@ -0,0 +1,56 @@ +// 5sing 歌曲搜索(上游 fivesing/song.go 的 Search 移植) +// 接口: GET http://search.5sing.kugou.com/home/json?keyword=&sort=1&page=&filter=0&type=0 +// 返回 { list: [...], type, pageInfo: { cur, totalCount, totalPages } },每页 10 条(服务端固定)。 +// 内容种类:typeEname = yc(原创) / fc(翻唱) / bz(伴奏),中文名在 typeName;上游以 typeEname 入 ID/Extra, +// 本移植把它作为统一种类字段 songType 暴露(songmid 约定仍为 "|")。 +// 上游 Search() 无 page 参数(硬编码 page=1);本移植按契约透传 page(实测 page 参数有效,pageInfo 可用于翻页)。 +// 时长:上游用 songSize 估算(songSize*8/320000 秒);实测多数条目 songSize=0 → interval 返回 0。 + +import { formatPlayTime } from '../common/utils.ts' +import { SEARCH_API, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, estimateDuration, kindName } from './util.ts' + +/** 上游 Search 的 list 项 → 统一 Song 形状(source 统一 '5sing') */ +export const mapSearchSong = (item: any) => { + const songType = String(item?.typeEname ?? '') + const duration = estimateDuration(Number(item?.songSize) || 0) + return { + songmid: `${item?.songId ?? ''}|${songType}`, + name: removeEmTags(unescapeHtml(item?.songName)), + singer: removeEmTags(unescapeHtml(item?.singer ?? item?.nickName)), + albumId: '', + albumName: '', + interval: duration > 0 ? formatPlayTime(duration) : 0, + img: null, + lrc: null, + types: [], + _types: {}, + // 统一种类字段(上游 typeEname / typeName) + songType, + typeName: String(item?.typeName ?? kindName(songType)), + size: Number(item?.songSize) || 0, + link: buildSongLink(songType, item?.songId), + source: '5sing' as const, + } +} + +export const search = async(keyword: string, page = 1, limit = 20) => { + const word = String(keyword ?? '').trim() + const pageNo = Math.max(1, Number(page) || 1) + const pageSize = Math.max(1, Number(limit) || 20) + const empty = { list: [] as any[], allPage: 0, total: 0, limit: pageSize, source: '5sing' as const } + if (!word) return empty + + const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=0` + const { statusCode, body } = await fetch5singJson(url) + if (statusCode != 200) throw new Error(`5sing 搜索失败(HTTP ${statusCode})`) + + const rawList: any[] = Array.isArray(body?.list) ? body.list : [] + const list = rawList.slice(0, pageSize).map(mapSearchSong) + const pageInfo = body?.pageInfo ?? {} + const total = Number(pageInfo.totalCount) || list.length + // 服务端每页 10 条(固定),allPage 直接用 pageInfo.totalPages + const allPage = Number(pageInfo.totalPages) || (list.length >= pageSize ? pageNo + 1 : pageNo) + return { list, allPage, total, limit: pageSize, source: '5sing' as const } +} + +export default { search } diff --git a/sdk/5sing/parse.ts b/sdk/5sing/parse.ts new file mode 100644 index 0000000..4b4d9a3 --- /dev/null +++ b/sdk/5sing/parse.ts @@ -0,0 +1,18 @@ +// 5sing 链接解析(上游 fivesing/song.go 的 Parse + playlist.go 的 ParsePlaylist 移植) +// 歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetchSongInfo(直链 + newget 元数据,与上游一致:无直链即报错) +// 歌单: /{userId}/dj/.html → getPlaylistDetail 全量曲目(支持个性域名 userId,见 util.extractPlaylistLink) +// 专辑: 上游无专辑概念(不实现) + +import { extractSongLink } from './util.ts' +import { fetchSongInfo } from './musicInfo.ts' +import { parsePlaylistLink } from './playlist.ts' + +export const parseSong = async(link: string) => { + const parsed = extractSongLink(link) + if (!parsed) throw new Error('invalid 5sing link') + return fetchSongInfo(parsed.songID, parsed.songType) +} + +export const parsePlaylist = async(link: string) => parsePlaylistLink(link) + +export default { parseSong, parsePlaylist } diff --git a/sdk/5sing/playlist.ts b/sdk/5sing/playlist.ts new file mode 100644 index 0000000..2dec0ab --- /dev/null +++ b/sdk/5sing/playlist.ts @@ -0,0 +1,251 @@ +// 5sing 歌单(上游 fivesing/fivesing.go + playlist.go 移植) +// 搜索: GET http://search.5sing.kugou.com/home/json?...&type=1 → list[].songListId/title/pictureUrl/playCount/userName/songCnt/content/userId +// 上游对 userName 为空的条目再调 getsonglist?songfields=user 补创建者名(并发 10);本移植用同样回退、并发 5。 +// 详情: ① GET mobileapi.5sing.kugou.com/song/getsonglist?id=&songfields=ID,user(元数据 T/C/P/H/E + user.ID/NN) +// ② 抓 http://5sing.kugou.com/{userId}/dj/{id}.html 解析
  • 块(上游 parseSongsFromHTML) +// ③ 数字 userId 页会 302 到个性域名页(如 /yeluoluo/dj/...),httpFetch 不跟跳 → util.fetch5sing 手动跟跳。 +// 分类/个人歌单:上游显式返回不支持(model.ErrPlaylistCategoriesUnsupported / ErrUserPlaylistsUnsupported),本移植保留同样语义(抛错)。 +// 链接解析: 上游正则只认数字 userId;本移植放宽支持个性域名(/name/dj/xxx.html)与省略 user 段,ID 以 API 反查为准。 + +import { MOBILE_API, SEARCH_API, fetch5sing, fetch5singJson, removeEmTags, unescapeHtml, buildSongLink, buildPlaylistLink, extractPlaylistLink, kindName } from './util.ts' + +/** 上游 GetPlaylistCategories / GetCategoryPlaylists:显式不支持 */ +export const getCategories = async() => { + throw new Error('playlist categories not supported') +} +export const getCategoryPlaylists = async(_categoryId?: any, _page = 1, _limit = 30) => { + throw new Error('playlist categories not supported') +} + +/** 歌单列表项(HTML 解析出的歌曲)→ 统一 Song 形状 */ +const mapPlaylistSong = (songID: string, kind: string, name: string, artist: string) => ({ + songmid: `${songID}|${kind}`, + name, + singer: artist, + albumId: '', + albumName: '', + interval: 0, + img: null, + lrc: null, + types: [], + _types: {}, + songType: kind, + typeName: kindName(kind), + link: buildSongLink(kind, songID), + source: '5sing' as const, +}) + +/** + * 上游 parseSongsFromHTML:从歌单页 HTML 提取歌曲。 + * 块正则 `
  • ([\s\S]*?)
  • `;块内 href 提取 yc/fc/bz 与 id,s_soner 提取歌手(上游的拼写错误 soner 保留)。 + */ +export const parseSongsFromHTML = (htmlContent: string) => { + const blocks = [...String(htmlContent ?? '').matchAll(/
  • ([\s\S]*?)<\/li>/g)] + if (!blocks.length) throw new Error('no songs found in playlist html (structure mismatch)') + + // 上游正则要求 href="http://5sing.kugou.com/...";这里放宽协议(http/https/协议相对) + const songRe = /href="(?:(?:https?:)?\/\/)?5sing\.kugou\.com\/(yc|fc|bz)\/(\d+)\.html"[^>]*>([^<]+)<\/a>/ + const artistRe = /class="s_soner[^"]*".*?>([^<]+)<\/a>/ + + const songs: any[] = [] + const seen = new Set() + for (const match of blocks) { + const blockHTML = match[1] + const songMatch = songRe.exec(blockHTML) + if (!songMatch) continue + const kind = songMatch[1] + const songID = songMatch[2] + const rawName = songMatch[3] + + let artist = 'Unknown' + const artistMatch = artistRe.exec(blockHTML) + if (artistMatch) artist = artistMatch[1] + + const uniqueKey = `${kind}|${songID}` + if (seen.has(uniqueKey)) continue + seen.add(uniqueKey) + + const name = removeEmTags(unescapeHtml(rawName)).trim() + artist = removeEmTags(unescapeHtml(artist)).trim() + songs.push(mapPlaylistSong(songID, kind, name, artist)) + } + return songs +} + +/** 上游 fetchCreatorName:userName 为空时用 getsonglist?songfields=user 反查创建者名(data 必须是对象) */ +export const fetchCreatorName = async(playlistId: string): Promise => { + if (!playlistId) return '' + try { + const { statusCode, body } = await fetch5singJson(`${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=user`) + if (statusCode != 200 || !body || typeof body !== 'object') return '' + const data = body.data + if (!data || typeof data !== 'object' || Array.isArray(data)) return '' + return String(data?.user?.NN ?? '') + } catch { + return '' + } +} + +/** 歌单搜索项 → 摘要(与既有平台 playlist 搜索项对齐) */ +const mapPlaylistSummary = (item: any) => { + const id = String(item?.songListId ?? '') + const userId = String(item?.userId ?? '') + const desc = removeEmTags(unescapeHtml(item?.content)) + const creator = String(item?.userName ?? '') || (userId ? `ID: ${userId}` : '') + return { + id, + name: removeEmTags(unescapeHtml(item?.title)), + cover: String(item?.pictureUrl ?? ''), + trackCount: Number(item?.songCnt) || 0, + playCount: Number(item?.playCount) || 0, + creator, + creatorId: userId, + // 上游怪癖:content 为字符串 "0" 时视为空简介 + description: desc === '0' ? '' : desc, + link: buildPlaylistLink(userId, id), + source: '5sing' as const, + } +} + +/** 上游 SearchPlaylist(type=1),补创建者名(并发 5) */ +export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => { + const word = String(keyword ?? '').trim() + const pageNo = Math.max(1, Number(page) || 1) + const pageSize = Math.max(1, Number(limit) || 20) + const empty = { list: [] as any[], total: 0, page: pageNo, limit: pageSize, source: '5sing' as const } + if (!word) return empty + + const url = `${SEARCH_API}?keyword=${encodeURIComponent(word)}&sort=1&page=${pageNo}&filter=0&type=1` + const { statusCode, body } = await fetch5singJson(url) + if (statusCode != 200) throw new Error(`5sing 歌单搜索失败(HTTP ${statusCode})`) + + const rawList: any[] = Array.isArray(body?.list) ? body.list : [] + const list = rawList.slice(0, pageSize).map(mapPlaylistSummary) + + // 上游并行回退:item.userName 为空时反查真实创建者名(失败保留 "ID: xxx") + const targets = list.filter((pl) => pl.creatorId && pl.creator === `ID: ${pl.creatorId}`) + for (let i = 0; i < targets.length; i += 5) { + await Promise.all(targets.slice(i, i + 5).map(async(pl) => { + const name = await fetchCreatorName(pl.id) + if (name) pl.creator = name + })) + } + + const total = Number(body?.pageInfo?.totalCount) || list.length + return { list, total, page: pageNo, limit: pageSize, source: '5sing' as const } +} + +/** 元数据 + 全量曲目(上游 fetchPlaylistDetail 核心;HTML 不分页,一次拿全) */ +export const fetchPlaylistAll = async(playlistIdRaw: string) => { + const playlistId = String(playlistIdRaw ?? '').trim() + if (!playlistId) throw new Error('invalid 5sing playlist id') + + // 1) API 元数据(标题/封面/统计/UserID) + const { statusCode, body } = await fetch5singJson( + `${MOBILE_API}/song/getsonglist?id=${encodeURIComponent(playlistId)}&songfields=ID,user`, + ) + if (statusCode != 200) throw new Error(`fetch info failed: HTTP ${statusCode}`) + + // 上游用 RawMessage 判多态:歌单不存在/无权限时 data 为 [](数组),直接报错 + const data = body?.data + if (!data || typeof data !== 'object' || Array.isArray(data)) { + throw new Error('playlist info not found or invalid (api returned empty list)') + } + + const userId = String(data?.user?.ID ?? '') + if (!userId) throw new Error('playlist user not found or invalid id') + + const playlist = { + id: playlistId, + name: String(data?.T ?? ''), + cover: String(data?.P ?? ''), + trackCount: Number(data?.E) || 0, + playCount: Number(data?.H) || 0, + creator: String(data?.user?.NN ?? ''), + description: String(data?.C ?? ''), + link: buildPlaylistLink(userId, playlistId), + userId, + source: '5sing' as const, + } + + // 2) 歌单页 HTML(数字 userId 会 302 → 个性域名页,fetch5sing 已跟跳) + const page = await fetch5sing(playlist.link) + if (page.statusCode != 200) throw new Error(`fetch html failed: HTTP ${page.statusCode}`) + + // 3) 解析曲目(上游:解析失败不影响元数据返回,返回空列表) + let songs: any[] = [] + try { + songs = parseSongsFromHTML(String(page.body ?? '')) + } catch { + songs = [] + } + return { playlist, songs } +} + +/** 歌单详情(分页切片;total 为解析到的有效曲目数,trackCount 保留上游元数据 E) */ +export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => { + const { playlist, songs } = await fetchPlaylistAll(id) + const pageNo = Math.max(1, Number(page) || 1) + const pageSize = Math.max(1, Number(limit) || 50) + const start = (pageNo - 1) * pageSize + const list = songs.slice(start, start + pageSize) + const total = songs.length + return { + source: '5sing' as const, + info: { + id: playlist.id, + name: playlist.name, + img: playlist.cover, + desc: playlist.description, + author: playlist.creator, + link: playlist.link, + trackCount: playlist.trackCount, + playCount: playlist.playCount, + }, + list, + page: pageNo, + limit: pageSize, + total, + allPage: Math.ceil(total / pageSize) || 1, + } +} + +/** 上游 GetPlaylistSongs:仅返回曲目列表(全量) */ +export const getPlaylistSongs = async(id: string) => { + const { songs } = await fetchPlaylistAll(id) + return songs +} + +/** 上游 ParsePlaylist:歌单链接 → 详情(全量曲目,匹配上游返回全部歌曲的行为) */ +export const parsePlaylistLink = async(link: string) => { + const parsed = extractPlaylistLink(link) + if (!parsed) throw new Error('invalid 5sing playlist link') + const { playlist, songs } = await fetchPlaylistAll(parsed.playlistId) + return { + source: '5sing' as const, + info: { + id: playlist.id, + name: playlist.name, + img: playlist.cover, + desc: playlist.description, + author: playlist.creator, + link: playlist.link, + trackCount: playlist.trackCount, + playCount: playlist.playCount, + }, + list: songs, + page: 1, + limit: songs.length || 1, + total: songs.length, + allPage: 1, + } +} + +export default { + searchPlaylist, + getPlaylistDetail, + getPlaylistSongs, + getCategories, + getCategoryPlaylists, + parsePlaylistLink, +} diff --git a/sdk/5sing/util.ts b/sdk/5sing/util.ts new file mode 100644 index 0000000..1f45fb0 --- /dev/null +++ b/sdk/5sing/util.ts @@ -0,0 +1,188 @@ +// 5sing 公共工具(上游 fivesing/fivesing.go 的 getFirstValid / removeEmTags 等移植) +// 平台 id 统一为 '5sing'(上游包名 fivesing,Go 源码 model.Source 用 "fivesing"; +// 本仓库契约要求 id 为 5sing,故所有对外 source 字段均为 '5sing'。 +// 上游 New(cookie) 的 cookie 实际参与请求(搜索/getSongUrl/newget/歌单页), +// 这里按约定从 sdk/auth/store.ts 的 getCredentials('5sing') 读取(无则匿名)。 + +import { httpFetch } from '../request.ts' +import { getCredentials } from '../auth/store.ts' + +export const UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36' + +/** 上游 fivesing.go 的常量:手机端 API 主机(http 明文,实测无 302) */ +export const MOBILE_API = 'http://mobileapi.5sing.kugou.com' +/** 搜索接口(type=0 单曲 / type=1 歌单) */ +export const SEARCH_API = 'http://search.5sing.kugou.com/home/json' + +/** 作品类型(上游 typeEname;yc=原创 fc=翻唱 bz=伴奏) */ +export const SONG_KINDS: Record = { + yc: '原创', + fc: '翻唱', + bz: '伴奏', +} + +/** 上游 getFirstValid:返回第一个非空字符串(空串视为无效) */ +export const getFirstValid = (...urls: Array): string => { + for (const u of urls) { + if (u) return String(u) + } + return '' +} + +/** 上游 removeEmTags:去掉搜索结果里 高亮标签 */ +export const removeEmTags = (s: any): string => + String(s ?? '') + .replaceAll('', '') + .replaceAll('', '') + .trim() + +const HTML_ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', + copy: '©', + reg: '®', + hellip: '…', + mdash: '—', + ndash: '–', +} + +/** Go html.UnescapeString 的等价实现(常用实体 + 数字实体) */ +export const unescapeHtml = (s: any): string => + String(s ?? '').replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => { + if (entity.startsWith('#x') || entity.startsWith('#X')) { + const code = Number.parseInt(entity.slice(2), 16) + return Number.isFinite(code) ? String.fromCodePoint(code) : match + } + if (entity.startsWith('#')) { + const code = Number.parseInt(entity.slice(1), 10) + return Number.isFinite(code) ? String.fromCodePoint(code) : match + } + return HTML_ENTITIES[entity.toLowerCase()] ?? match + }) + +/** 上游 New(cookie):cookie 仅在有值时随请求发送(匿名可用) */ +export const get5singCookie = (): string => String((getCredentials('5sing') as { cookie?: string } | null)?.cookie ?? '') + +/** 登录态(凭据存在性判断,与 me 平台 status 对齐) */ +export const getStatus = () => { + const cred = getCredentials('5sing') as { cookie?: string, updatedAt?: string } | null + return cred?.cookie ? { logged: true, updatedAt: cred.updatedAt } : { logged: false } +} + +/** 是否显式声明了非本平台的 source(上游 GetLyrics/GetDownloadURL 的 source mismatch 检查) */ +export const isForeignSource = (songInfo: any): boolean => + Boolean(songInfo?.source) && songInfo.source !== '5sing' + +/** + * 带 Cookie/UA 的请求(上游 utils.Get + http.Client 自动跟跳的等价实现) + * httpFetch 不跟随 302:这里手动跟跳最多 3 次(5sing 老链接 http→https / 数字 userId→个性域名)。 + */ +export const fetch5sing = async( + url: string, + options: { headers?: Record, timeout?: number, register?: (request: any) => void } = {}, +): Promise => { + const cookie = get5singCookie() + const headers = { + 'User-Agent': UserAgent, + ...(cookie ? { Cookie: cookie } : {}), + ...(options.headers || {}), + } + let current = String(url) + let response: any = null + for (let hop = 0; hop <= 3; hop++) { + const request = httpFetch(current, { headers, timeout: options.timeout ?? 15000 }) + options.register?.(request) + response = await request.promise + const status = Number(response?.statusCode) || 0 + const location = response?.headers?.location + if (status >= 300 && status < 400 && location) { + try { + current = new URL(String(location), current).href + } catch { + return response + } + continue + } + return response + } + return response +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** + * 5sing 手机端 API 有频率控制(实测连续请求会返回 { code: 13, message: "操作太频繁", data: [] }), + * 上游 Go 无重试;本移植对 JSON 接口做最多 2 次退避重试,保证搜索/取流/歌词/歌单在突发请求下稳定。 + */ +export const fetch5singJson = async( + url: string, + options: { headers?: Record, timeout?: number, register?: (request: any) => void } = {}, +): Promise => { + let response: any = null + for (let attempt = 0; attempt < 3; attempt++) { + response = await fetch5sing(url, options) + const code = Number(response?.body?.code) + const message = String(response?.body?.message ?? '') + if ((code === 13 || message.includes('太频繁')) && attempt < 2) { + await sleep(800 * (attempt + 1)) + continue + } + return response + } + return response +} + +/** 歌曲页链接(上游 Link 拼法:http://5sing.kugou.com/{typeEname}/{songId}.html) */ +export const buildSongLink = (songType: string, songId: string | number): string => + songType && songId ? `http://5sing.kugou.com/${songType}/${songId}.html` : '' + +/** 歌单页链接(上游 Link 拼法:http://5sing.kugou.com/{userId}/dj/{playlistId}.html) */ +export const buildPlaylistLink = (userId: string | number, playlistId: string): string => + userId && playlistId ? `http://5sing.kugou.com/${userId}/dj/${playlistId}.html` : '' + +/** + * 解析歌曲的 | 约定(上游 model.Song.ID: "123|yc" + Extra{songid,songtype}) + * 兼容只给了 songType 字段(路由层只透传 songmid 时用 `|` 分隔)。 + */ +export const extractSongId = ( + songmid: any, + songTypeFallback?: any, +): { songID: string, songType: string } => { + const raw = String(songmid ?? '') + const [idPart = '', typePart = ''] = raw.split('|') + return { + songID: idPart || '', + songType: String(typePart || songTypeFallback || ''), + } +} + +/** 上游 Parse 的正则:5sing.kugou.com/(\w+)/(\d+).html(专辑/歌手页不匹配) */ +export const extractSongLink = (link: any): { songType: string, songID: string } | null => { + const match = /5sing\.kugou\.com\/(\w+)\/(\d+)\.html/.exec(String(link ?? '')) + if (!match) return null + return { songType: match[1], songID: match[2] } +} + +/** + * 上游 ParsePlaylist 的正则增强: + * 原正则 `5sing\.kugou\.com/(?:(\d+)/)?dj/([a-zA-Z0-9]+)\.html` 只认数字 userId, + * 个性域名(如 /yeluoluo/dj/xxx.html)与省略 user 段的链接会漏;userId 本身不参与请求(API 反查更准),故放宽为任意单段。 + */ +export const extractPlaylistLink = (link: any): { userId: string, playlistId: string } | null => { + const match = /5sing\.kugou\.com\/(?:([^/?#]+)\/)?dj\/([a-zA-Z0-9]+)\.html/.exec(String(link ?? '')) + if (!match) return null + return { userId: match[1] || '', playlistId: match[2] } +} + +/** 上游 Search 的时长估算:songSize*8/320000(320kbps 假设;songSize 为 0 时返回 0) */ +export const estimateDuration = (songSize: number): number => { + const size = Number(songSize) || 0 + return size > 0 ? Math.trunc((size * 8) / 320000) : 0 +} + +/** 作品类型中文名(typeName 缺失时兜底) */ +export const kindName = (songType: string): string => SONG_KINDS[songType] ?? '' diff --git a/sdk/apis/5sing.ts b/sdk/apis/5sing.ts new file mode 100644 index 0000000..edd211d --- /dev/null +++ b/sdk/apis/5sing.ts @@ -0,0 +1,53 @@ +// 5sing 取流(上游 fivesing/download.go 的 GetDownloadURL + fivesing.go 的 fetchAudioLink 移植) +// +// 链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=&songtype= +// → { code: 1000, data: { squrl/squrl_backup, hqurl/hqurl_backup, lqurl/lqurl_backup, sqsize/hqsize/lqsize } } +// 直链为 CDN 明文地址(wsaudiobssdlbig.kugou.com,https,无需 Referer/防盗链签名;实测 Range 206 可播)。 +// 说明:上游 fivesing 包内没有 base64/加密参数逻辑(不存在 _sign/song_id 签名),取流就是上述明文 GET; +// 数字 userId 的网页链接 302 的问题只影响歌单页,与取流无关。 +// 档位:上游固定 sq(最高,常为无损/高码)→ hq → lq,返回首个非空(含 _backup); +// 本移植默认沿用该顺序,只有显式请求 64k(或 type=lq)时才倒序优先 lq,返回实际命中档位。 +// songmid 约定 "|";Parse 出来的歌曲带 url 时直接复用(上游 GetDownloadURL: if s.URL != "" return)。 + +import { fetchAudioLink } from '../5sing/musicInfo.ts' +import { extractSongId, isForeignSource } from '../5sing/util.ts' + +/** 上游只有 sq/hq/lq 三档,实际命中档位以返回 type 为准(sq 常缺,多命中 hq) */ +export const supportQualitys = ['sq', 'hq', 'lq'] + +/** 上游 GetDownloadURL 的等价实现(非取消句柄版):register 用于取消当前请求 */ +export const getDownloadUrl = async(songInfo: any, type?: string, register?: (request: any) => void) => { + if (isForeignSource(songInfo)) throw new Error('source mismatch') + // 上游逻辑:Song.URL 已带直链(Parse 场景)直接复用 + if (songInfo?.url) { + return { type: String(songInfo?.type || type || 'sq'), url: String(songInfo.url) } + } + const { songID, songType } = extractSongId(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid, songInfo?.songType) + if (!songID || !songType) { + throw new Error('invalid id structure: 5sing songmid 需为 "|"(songType 为 yc/fc/bz)') + } + const preferLow = type === '64k' || type === 'lq' + return fetchAudioLink(songID, songType, register, preferLow) +} + +/** + * 统一取流接口:getMusicUrl(songInfo, type) → { promise, cancelHttp } + * promise resolve → { type: 'sq'|'hq'|'lq', url }(type 为实际命中档位,可能低于请求档位) + */ +export const getMusicUrl = (songInfo: any, type?: string) => { + const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} } + let current: any = null + requestObj.cancelHttp = () => { + current?.cancelHttp?.() + current = null + } + + requestObj.promise = (async() => { + const hit = await getDownloadUrl(songInfo, type, (request) => { current = request }) + return { type: hit.type, url: hit.url } + })() + + return requestObj +} + +export default { getMusicUrl, getDownloadUrl, supportQualitys } diff --git a/sdk/apis/apple.ts b/sdk/apis/apple.ts new file mode 100644 index 0000000..da1b742 --- /dev/null +++ b/sdk/apis/apple.ts @@ -0,0 +1,72 @@ +// Apple Music 取流(上游 apple.go GetDownloadURL) +// ⚠️ Apple 只提供 **preview 试听片段**(audio-ssl.itunes.apple.com,AAC/m4a,约 30-90 秒,实测单文件 ~3MB), +// 完整曲目是 FairPlay DRM 加密流(extendedAssetUrls.enhancedHls),需要 gamdl 等外部工具解密, +// 本 SDK 不提供。因此这里**任何请求档位都返回 type='preview'**,并在注释/响应里明确标注,不假装是无损完整曲。 +// 歌曲的 previews[0].url 来自 catalog 歌曲详情(搜索结果缓存里的 previewUrl 也会直接复用,同上游 s.URL 逻辑)。 +// 统一接口约定:apis('apple').getMusicUrl(songInfo, type) → { promise, cancelHttp } +// promise resolve → { type:'preview', url, ext:'m4a', preview:true, songDuration, isrc } +// (songDuration 是歌曲完整时长,试听片段本身约 30-90 秒) +// type 请求参数不影响结果(只有 preview 一档);supportQualitys 声明为 ['128k'] 以对齐 +// /sources 与 /url?quality 枚举,返回的实际档位以 type='preview' 为准。 +import { appleAmpGetTracked, extractAppleID, getAppleConfig } from '../apple/util.ts' + +export const supportQualitys = ['128k'] + +const isAudioURL = (value: any): boolean => /^https?:/i.test(String(value ?? '')) + +/** 详情 → preview 结果(内部/测试用) */ +export const previewFromAttributes = (attr: any) => { + const url = String(attr?.previews?.[0]?.url ?? '') + if (!url) { + throw new Error('apple music: no preview URL available (完整曲目受 DRM 保护,SDK 仅提供 30-90 秒试听片段)') + } + return { + type: 'preview', + url, + ext: 'm4a', + preview: true, + // 注意:这是歌曲完整时长(catalog durationInMillis),不是试听片段长度——试听片段约 30-90 秒 + songDuration: Math.round(Number(attr?.durationInMillis ?? 0) / 1000), + isrc: attr?.isrc || '', + } +} + +/** 按 song id 拉取 preview(上游 GetDownloadURL → Parse) */ +export const getPreviewUrl = async(songId: string, tracker?: { current: any }) => { + const cfg = getAppleConfig() + const body: any = await appleAmpGetTracked( + `/v1/catalog/${cfg.storefront}/songs/${songId}`, + { extend: 'extendedAssetUrls' }, + tracker, + ) + const data = body?.data ?? [] + if (!data.length) throw new Error(`apple song not found: ${songId}`) + return previewFromAttributes(data[0]?.attributes) +} + +export const getMusicUrl = (songInfo: any, _type?: string) => { + const requestObj: any = { promise: null as any, cancelHttp() {} } + const tracker: { current: any } = { current: null } + requestObj.cancelHttp = () => { + try { + tracker.current?.cancelHttp?.() + } catch { + // ignore + } + tracker.current = null + } + + requestObj.promise = (async() => { + // 上游 GetDownloadURL:Song.URL 已有直链时直接复用(搜索结果里的 previewUrl / 链接解析结果) + if (isAudioURL(songInfo?.previewUrl)) return { type: 'preview', url: String(songInfo.previewUrl), ext: 'm4a', preview: true } + if (isAudioURL(songInfo?.url)) return { type: 'preview', url: String(songInfo.url), ext: 'm4a', preview: true } + + const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song') + if (!songId) throw new Error('apple 歌曲 id 无效(需要数字 song id 或 Apple Music 歌曲链接)') + return getPreviewUrl(songId, tracker) + })() + + return requestObj +} + +export default { getMusicUrl, getPreviewUrl, previewFromAttributes, supportQualitys } diff --git a/sdk/apis/bili.ts b/sdk/apis/bili.ts new file mode 100644 index 0000000..b5dfe8a --- /dev/null +++ b/sdk/apis/bili.ts @@ -0,0 +1,158 @@ +// Bilibili 取流(上游 bilibili/bilibili.go 的 fetchAudioURL + account.go 的 IsVipAccount) +// 接口: https://api.bilibili.com/x/player/playurl?fnval=&qn=127&bvid=&cid= +// fnval=80 标准 DASH(匿名可用) +// fnval=4048 额外请求 FLAC/Hi-Res/Dolby(上游逻辑:大会员才用;实测匿名请求该参数也不报错,只是没有 flac 节点) +// 档位选择沿用上游优先级:Hi-Res FLAC(30251) → Dolby(30250) → DASH 标准最高档(30280/30232/30216) → durl 兜底; +// 仅当显式请求 64k 时优先 30216(上游无此参数意识,移植补的最小档位选择)。 +// 302:httpFetch 不跟随重定向,这里用 `GET + Range: bytes=0-0` 手动跟跳最多 3 跳(HEAD 会被 CDN 404,不能用), +// 失败时回退原始地址,保证客户端仍能自行跟跳。 +// 参考 Referer/UA 必须携带,否则 CDN 可能 403。 + +import { httpFetch } from '../common/request.ts' +import { biliHeaders, fetchView, getCookie, parseSongId } from '../bili/util.ts' + +// 与 routes.ts 的 QUALITIES 枚举对齐(132k/192k 为 B 站实际档位,命中时按实际返回) +export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit'] + +const QUALITY_LABEL: Record = { + 30216: '64k', + 30232: '132k', + 30280: '192k', + 30250: 'flac', + 30251: 'flac24bit', +} + +let vipCache: { value: boolean, at: number } | null = null + +/** 登录态清空/更新后调用,丢弃大会员判断缓存 */ +export const resetVipCache = () => { + vipCache = null +} + +/** 上游 IsVipAccount:x/web-interface/nav 的 isLogin + vipStatus + vipType(进程内缓存 5 分钟) */ +export const isVipAccount = async(cookie: string): Promise => { + if (!cookie) return false + if (vipCache && Date.now() - vipCache.at < 5 * 60 * 1000) return vipCache.value + try { + const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', { + headers: biliHeaders(cookie), + timeout: 15000, + }).promise as any + const data = body?.data ?? {} + const vip = body?.code === 0 && !!data.isLogin && Number(data.vipStatus) === 1 && Number(data.vipType) > 0 + vipCache = { value: vip, at: Date.now() } + return vip + } catch { + return false + } +} + +const pickAudio = (data: any, type: string): { id: number, url: string, label: string } | null => { + const dash = data?.dash ?? {} + const flac = dash?.flac?.audio + const hires = flac && Number(flac.id) === 30251 && flac.baseUrl ? { id: 30251, url: String(flac.baseUrl) } : null + const dolby = (dash?.dolby?.audio ?? []).find((a: any) => Number(a?.id) === 30250 && a?.baseUrl) + const standards: any[] = (dash?.audio ?? []) + .filter((a: any) => a?.baseUrl) + .sort((a: any, b: any) => Number(b.id) - Number(a.id)) + + if (type === '64k') { + const low = standards.find(a => Number(a.id) === 30216) + if (low) return { id: 30216, url: String(low.baseUrl), label: QUALITY_LABEL[30216] } + } + + // 上游优先级:Hi-Res FLAC → Dolby → 最高标准档 + if (hires) return { ...hires, label: QUALITY_LABEL[30251] } + if (dolby) return { id: 30250, url: String(dolby.baseUrl), label: QUALITY_LABEL[30250] } + if (standards.length) { + const best = standards[0] + return { id: Number(best.id), url: String(best.baseUrl), label: QUALITY_LABEL[Number(best.id)] ?? `audio-${best.id}` } + } + const durl = data?.durl?.[0]?.url + if (durl) return { id: 0, url: String(durl), label: 'durl' } + return null +} + +/** 播放地址 API(上游 fetchAudioURL 的等价实现,返回 { type, url };不做 CDN 跟跳) */ +export const getAudioUrl = async(bvid: string, cid: string | number, type = '128k'): Promise<{ type: string, url: string }> => { + const cookie = await getCookie() + const needFlac = type === 'flac' || type === 'flac24bit' + const vip = await isVipAccount(cookie) + const fnval = needFlac || vip ? 4048 : 80 + const url = `https://api.bilibili.com/x/player/playurl?fnval=${fnval}&qn=127&bvid=${encodeURIComponent(bvid)}&cid=${encodeURIComponent(String(cid))}` + const { statusCode, body } = await httpFetch(url, { headers: biliHeaders(cookie), timeout: 15000 }).promise as any + if (statusCode !== 200 || !body || typeof body !== 'object') throw new Error(`B站取流失败(HTTP ${statusCode})`) + if (body.code !== 0) throw new Error(`B站取流失败(${body.code}${body.message ? `: ${body.message}` : ''})`) + const picked = pickAudio(body.data, type) + if (!picked) throw new Error('B站未返回可用音频流') + return { type: picked.label, url: picked.url } +} + +/** 手动跟随 CDN 302(GET + Range 只读首字节;失败回退当前地址) */ +const followRedirects = async(url: string, cookie: string, tracker?: { current: any }): Promise => { + let current = String(url) + for (let hop = 0; hop < 3; hop++) { + let response: any + try { + const request = httpFetch(current, { + method: 'get', + headers: { ...biliHeaders(cookie), Range: 'bytes=0-0' }, + timeout: 15000, + }) + if (tracker) tracker.current = request + response = await request.promise + } catch { + return current + } + const statusCode = Number(response?.statusCode) || 0 + const location = response?.headers?.location + if (statusCode >= 300 && statusCode < 400 && location) { + try { + current = new URL(String(location), current).href + continue + } catch { + return current + } + } + return current + } + return current +} + +/** + * 统一取流接口:apis('bili').getMusicUrl(songInfo, type) → { promise, cancelHttp } + * promise resolve → { type, url }(type 为实际命中档位,可能高于/低于请求档位) + * songInfo.songmid 约定 `|`;仅给 bvid 时回源 view 取首P cid。 + */ +export const getMusicUrl = (songInfo: any, type: string) => { + const tracker: { current: any } = { current: null } + const requestObj: any = { + cancelHttp() { + try { + tracker.current?.cancelHttp?.() + } catch { + // ignore + } + }, + promise: null as any, + } + + requestObj.promise = (async() => { + const { bvid, cid } = parseSongId(songInfo?.songmid ?? songInfo?.id) + if (!bvid) throw new Error('bili songmid 需为 | 格式') + let realCid = cid + if (!realCid) { + const view = await fetchView(bvid) + realCid = String(view?.pages?.[0]?.cid ?? '') + } + if (!realCid) throw new Error('B站未找到视频 cid(可能已失效)') + const info = await getAudioUrl(bvid, realCid, type) + const cookie = await getCookie() + const finalUrl = await followRedirects(info.url, cookie, tracker) + return { type: info.type, url: finalUrl } + })() + + return requestObj +} + +export default { getMusicUrl } diff --git a/sdk/apis/joox.ts b/sdk/apis/joox.ts new file mode 100644 index 0000000..f8f6da4 --- /dev/null +++ b/sdk/apis/joox.ts @@ -0,0 +1,49 @@ +// JOOX URL 获取(新版 web 播放器通道 openjoox2,含 md5(secretAPIKey) 签名) +// 链路:GET cache.api.joox.com/openjoox2/v1/track/?...&secret= +// → play_url_list[0](无则 refrain_url)→ 带 vkey/hdnts 的 CDN 直链(ak-hk.stream.music.joox.com 等) +// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(2026-10-02 实测), +// 故取流改用官网当前使用的 openjoox2 通道(签名细节见 sdk/joox/musicInfo.ts)。 +// 地区/签名参数:country=hk & lang=zh_TW & fs=1(请求完整曲目)& im=0,登录 cookie 的 +// wmid/session_key 会作为 uid/usk 参与签名(/login/joox 绑定后 VIP 曲目可取完整流)。 +// 实测:游客态返回 128k MP3(免费完整曲目)或 VIP 曲目的 preview/refrain 片段; +// 未登录请求 VIP 曲目时 play_url_list 为试听片段——与上游行为一致(上游同样只能拿游客流)。 +// 统一接口约定:apis('joox').getMusicUrl(songInfo, type) → { promise, cancelHttp } +// promise resolve → { type, url }(type 为实际命中档位,可能低于请求值) + +import { httpFetch } from '../request.ts' +import { jooxHeaders, normalizeJooxID } from '../joox/util.ts' +import { buildTrackUrl } from '../joox/musicInfo.ts' + +/** 游客通道实测仅 128k MP3;VIP 账号理论上更高,但需登录后验证,故不虚报 */ +export const supportQualitys = ['128k'] + +export const getMusicUrl = (songInfo: any, _type?: string) => { + const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} } + let current: any = null + requestObj.cancelHttp = () => { + if (current?.cancelHttp) current.cancelHttp() + } + + requestObj.promise = (async() => { + const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '') + if (!songId) throw new Error('joox 歌曲 id 无效') + + current = httpFetch(buildTrackUrl(songId, { fullSong: true }), { headers: jooxHeaders(), timeout: 20000 }) + const { statusCode, body } = await current.promise as any + if (statusCode != 200 || !body || typeof body !== 'object' || body.code) { + throw new Error(`joox 取流失败:${body?.msg || body?.message || `HTTP ${statusCode}`}`) + } + + const urls: any[] = Array.isArray(body.play_url_list) ? body.play_url_list : [] + const clean = (value: any) => String(value ?? '').replace(/&/g, '&') + let url = urls.map(clean).find(item => item.startsWith('http')) || '' + if (!url && clean(body.refrain_url).startsWith('http')) url = clean(body.refrain_url) + if (!url) throw new Error('无可用播放地址(VIP/版权限制或地区不可用)') + + return { type: '128k', url } + })() + + return requestObj +} + +export default { getMusicUrl } diff --git a/sdk/apis/migu.ts b/sdk/apis/migu.ts new file mode 100644 index 0000000..2891645 --- /dev/null +++ b/sdk/apis/migu.ts @@ -0,0 +1,366 @@ +// 咪咕取流 —— listen-url 多档位候选链 + 逐档探测(Range 0-63 验音频魔数/解密验证), +// 命中可访问的最高档位;匿名仅 PQ(128k) 可用,HQ/SQ/ZQ 各档需登录 Cookie。 +// 上游对应:music-lib/migu/download.go(GetDownloadInfo / buildMiguDownloadCandidates / +// selectHighestValidDownload / rewriteMiguToneURL / embeddedDownloadInfoFromResponse) +import { httpFetch } from '../request.ts' +import { + AndroidChannel, AndroidUA, AndroidVersion, buildUrl, decryptZ3D, firstNonEmpty, + getMiguCookie, isMiguAudioMagic, miguFormatExt, TONE_QUALITY, type MiguSongItem, +} from '../migu/util.ts' + +export const supportQualitys = ['64k', '128k', '320k', 'flac', 'flac24bit'] + +/** 平台档位 → 咪咕 toneFlag(取流请求用) */ +const QUALITY_TONE: Record = { + '64k': 'LQ', + '128k': 'PQ', + '320k': 'HQ', + flac: 'SQ', + flac24bit: 'ZQ24', + wav: 'ZQ32', +} + +const TONE_EXT: Record = { + LQ: 'mp3', PQ: 'mp3', HQ: 'mp3', SQ: 'flac', ZQ: 'flac', ZQ24: 'flac', + ZQ32: 'wav', Z3D: 'wav', I3D: 'm4a', '3D60': 'wav', +} + +/** 可重写的 tone 路径(上游 miguTonePaths) */ +const TONE_PATHS: Record = { + LQ: '全曲试听/Mp3_64_22_16', + PQ: '标清高清/MP3_128_16_Stero', + HQ: '标清高清/MP3_320_16_Stero', + SQ: '歌曲下载/flac', + ZQ: '歌曲下载/flac_24bit', + ZQ24: '歌曲下载/flac_24bit', + ZQ32: '歌曲下载/wav_32bit', +} + +const TONE_PREFERENCE = ['ZQ32', 'ZQ24', 'ZQ', 'SQ', 'HQ', 'Z3D', 'I3D', '3D60', 'PQ', 'LQ'] + +const normalizeTone = (value?: string): string => { + const v = String(value ?? '').trim().replace(/^\./, '').toUpperCase() + return TONE_EXT[v] ? v : '' +} + +/** songinfo → (contentId, resourceType, formatType):extra 优先,否则拆 songmid 的 `a|b|c` */ +export const miguSongParts = (songInfo: any): { contentId: string, resourceType: string, formatType: string } => { + const extra = songInfo?.extra ?? {} + let contentId = String(extra.content_id ?? '').trim() + let resourceType = String(extra.resource_type ?? '').trim() + let formatType = String(extra.format_type ?? '').trim() + if (contentId && resourceType && formatType) return { contentId, resourceType, formatType } + const parts = String(songInfo?.songmid ?? songInfo?.id ?? '').split('|') + if (parts.length === 3) { + return { contentId: parts[0]!.trim(), resourceType: parts[1]!.trim(), formatType: parts[2]!.trim() } + } + if (!contentId) contentId = String(songInfo?.songmid ?? songInfo?.id ?? '').trim() + return { contentId, resourceType, formatType } +} + +interface ListenResponse { + code?: string + info?: string + data?: { + url?: string + formatType?: string + dialogInfo?: { text?: string } + songItem?: MiguSongItem + } +} + +/** listen-url 请求(Android 客户端头);失败时抛出带 api 文案的错误 */ +const fetchListenInfo = async( + contentId: string, copyrightId: string, songId: string, albumId: string, resourceType: string, toneFlag: string, +): Promise => { + const params: Record = { + netType: '01', + resourceType: firstNonEmpty(resourceType, '2'), + contentId: contentId.trim(), + toneFlag: firstNonEmpty(toneFlag, 'PQ'), + } + if (copyrightId.trim()) params.copyrightId = copyrightId.trim() + if (songId.trim()) params.songId = songId.trim() + if (albumId.trim()) params.albumId = albumId.trim() + + const cookie = getMiguCookie() + const headers: Record = { + 'User-Agent': AndroidUA, + channel: AndroidChannel, + version: AndroidVersion, + Referer: 'https://music.migu.cn/', + } + if (cookie) headers.Cookie = cookie + + const { statusCode, body } = await httpFetch(`https://c.musicapp.migu.cn/MIGUM2.0/v2.1/content/listen-url?${new URLSearchParams(params)}`, { + headers, + timeout: 30000, + }).promise + if (statusCode < 200 || statusCode >= 300) throw new Error(`migu listen endpoint returned status ${statusCode}`) + const result = body as ListenResponse + if (result?.code && result.code !== '000000') throw new Error(`migu api error: ${result.info} (code ${result.code})`) + return result +} + +const normalizeDownloadURL = (rawURL?: string | null): string => { + let url = String(rawURL ?? '').trim() + if (!url) return '' + if (url.startsWith('//')) url = `https:${url}` + if (url.startsWith('ftp://218.200.160.122:21/')) { + url = `https://freetyst.nf.migu.cn/${url.slice('ftp://218.200.160.122:21/'.length)}` + } + return url +} + +const isEncryptedDirectURL = (rawURL: string): boolean => { + try { + const decodedPath = decodeURIComponent(new URL(rawURL).pathname) + return decodedPath.includes('wav_3d/') && !decodedPath.includes('wav_3d_60s/') + } catch { + return false + } +} + +interface DownloadInfo { + url: string + format: string + ext: string + encrypted: boolean + fileKey: string + size: number +} + +const directDownloadInfo = (rawURL: string, format: string, expectedSize: number): DownloadInfo | null => { + const url = normalizeDownloadURL(rawURL) + if (!url) return null + let ext = '' + try { + const path = new URL(url).pathname + ext = (path.split('.').pop() ?? '').toLowerCase() + if (!ext || ext.includes('/')) ext = '' + } catch { ext = '' } + let tone = normalizeTone(format) + if (!ext) ext = TONE_EXT[tone] ?? '' + if (!ext) ext = 'mp3' + if (!tone) { + // 按扩展名回填档位(上游 formatFromMiguExt) + switch (ext) { + case 'flac': tone = 'SQ'; break + case 'wav': tone = 'ZQ32'; break + case 'm4a': tone = 'I3D'; break + default: tone = 'PQ' + } + } + return { url, format: tone, ext, encrypted: false, fileKey: '', size: expectedSize } +} + +/** z3dCode 内嵌的加密候选(Z3D/I3D/3D60),上游 embeddedDownloadInfoFromResponse */ +const embeddedDownloadInfo = (resp: ListenResponse, targetTone: string, expectedSize: number): DownloadInfo | null => { + const code = resp?.data?.songItem?.z3dCode + if (!code) return null + const parseSize = (v?: string) => { + const n = parseInt(String(v ?? '').trim(), 10) + return Number.isFinite(n) && n > 0 ? n : 0 + } + switch (targetTone) { + case 'Z3D': { + const url = normalizeDownloadURL(firstNonEmpty(code.androidUrl, code.url)) + const fileKey = String(code.androidFileKey ?? '').trim() + if (!url || !fileKey) return null + return { + url, format: 'Z3D', + ext: firstNonEmpty(String(code.androidFileType ?? '').replace(/^\./, ''), 'wav'), + encrypted: true, fileKey, + size: parseSize(code.androidSize) || expectedSize, + } + } + case 'I3D': { + const url = normalizeDownloadURL(firstNonEmpty(code.iosUrl, code.url)) + const fileKey = String(code.iosFileKey ?? '').trim() + if (!url || !fileKey) return null + return { + url, format: 'I3D', + ext: firstNonEmpty(String(code.iosFileType ?? '').replace(/^\./, ''), 'm4a'), + encrypted: true, fileKey, + size: parseSize(code.iosSize) || expectedSize, + } + } + case '3D60': { + const url = normalizeDownloadURL(firstNonEmpty(code.url, code.h5Url)) + if (!url) return null + return { + url, format: '3D60', + ext: firstNonEmpty(String(code.fileType ?? '').replace(/^\./, ''), 'wav'), + encrypted: false, fileKey: '', + size: parseSize(code.size) || expectedSize, + } + } + default: + return null + } +} + +const detectTonePath = (decodedPath: string): string => { + for (const tone of ['ZQ24', 'ZQ', 'ZQ32', 'SQ', 'HQ', 'LQ', 'PQ']) { + const tonePath = TONE_PATHS[tone]! + if (tonePath && decodedPath.includes(tonePath)) return tonePath + } + return '' +} + +/** 把直链路径里的档位段替换为目标档位(上游 rewriteMiguToneURL) */ +const rewriteToneURL = (rawURL: string, targetTone: string): string | null => { + const tone = normalizeTone(targetTone) + const targetPath = TONE_PATHS[tone] + const targetExt = TONE_EXT[tone] + if (!targetPath || !targetExt || tone === 'Z3D' || tone === 'I3D' || tone === '3D60') return null + let parsed: URL + try { parsed = new URL(normalizeDownloadURL(rawURL)) } catch { return null } + const decodedPath = decodeURIComponent(parsed.pathname) + const sourcePath = detectTonePath(decodedPath) + if (!sourcePath) return null + let newPath = decodedPath.replace(sourcePath, targetPath) + if (newPath === decodedPath) return null + const dotIdx = newPath.lastIndexOf('.') + if (dotIdx > newPath.lastIndexOf('/')) newPath = newPath.slice(0, dotIdx) + newPath += `.${targetExt}` + parsed.pathname = newPath + return parsed.toString() +} + +/** 候选链:按档位优先级(high→low)产出直接/内嵌/重写三类候选(上游 buildMiguDownloadCandidates) */ +const buildCandidates = (responses: ListenResponse[], expectedSize: number): DownloadInfo[] => { + const sources: Array<{ url: string, format: string }> = [] + const seenSources = new Set() + for (const resp of responses) { + const candidateURL = normalizeDownloadURL(resp?.data?.url) + if (!candidateURL) continue + const actualFormat = normalizeTone(resp?.data?.formatType) || 'PQ' + const key = `${actualFormat}\u0000${candidateURL}` + if (seenSources.has(key)) continue + seenSources.add(key) + sources.push({ url: candidateURL, format: actualFormat }) + } + + const candidates: DownloadInfo[] = [] + const seen = new Set() + const add = (info: DownloadInfo | null) => { + if (!info || !info.url) return + const key = `${info.format}\u0000${info.url}` + if (seen.has(key)) return + seen.add(key) + candidates.push(info) + } + + for (const tone of TONE_PREFERENCE) { + for (const resp of responses) add(embeddedDownloadInfo(resp, tone, expectedSize)) + for (const source of sources) { + if (tone === source.format) { + add(directDownloadInfo(source.url, source.format, expectedSize)) + continue + } + const rewritten = rewriteToneURL(source.url, tone) + if (rewritten) add(directDownloadInfo(rewritten, tone, 0)) + } + } + return candidates +} + +/** 探测候选可用性:Range 0-63 校验音频魔数(加密流先解密再校验),并回填真实体积 */ +const downloadInfoValid = async(info: DownloadInfo): Promise => { + try { + const cookie = getMiguCookie() + const headers: Record = { 'User-Agent': AndroidUA, Referer: 'https://music.migu.cn/', Range: 'bytes=0-63' } + if (cookie) headers.Cookie = cookie + const { statusCode, headers: resHeaders, raw } = await httpFetch(info.url, { headers, timeout: 10000 }).promise + if (statusCode < 200 || statusCode >= 300) return false + const prefix = Buffer.isBuffer(raw) ? raw.subarray(0, 64) : Buffer.alloc(0) + if (!prefix.length) return false + if (info.encrypted) { + try { decryptZ3D(prefix, info.fileKey) } catch { return false } + } else if (!isMiguAudioMagic(prefix)) { + return false + } + const contentRange = String((resHeaders as any)['content-range'] ?? '') + const slash = contentRange.lastIndexOf('/') + if (slash >= 0) { + const size = parseInt(contentRange.slice(slash + 1).trim(), 10) + if (Number.isFinite(size) && size > 0) info.size = size + } + return true + } catch { + return false + } +} + +const MAX_PROBES = 10 + +export interface MiguUrlResult { + type: string + url: string + /** Z3D/I3D 加密流:URL 需解密后才能播放(本服务提供 /stream/migu 代理) */ + encrypted?: boolean + fileKey?: string + ext?: string + size?: number +} + +/** 取流入口:候选链 + 逐档探测;返回实际命中的最高档位 */ +export const getMusicUrl = (songInfo: any, type: string) => { + const requestObj = { cancelHttp() {}, promise: null as any } + requestObj.promise = (async(): Promise => { + const { contentId, resourceType, formatType } = miguSongParts(songInfo) + if (!contentId) throw new Error('migu 歌曲 id 无效(需 contentId 或 contentId|resourceType|formatType)') + const rt = resourceType || '2' + const targetTone = normalizeTone(QUALITY_TONE[type] ?? '') + || normalizeTone(formatType) + || normalizeTone(songInfo?.ext) + || 'PQ' + + const copyrightId = String(songInfo?.extra?.copyright_id ?? '') + const songId = String(songInfo?.extra?.song_id ?? '') + const albumId = String(songInfo?.extra?.album_id ?? songInfo?.albumId ?? '') + + const requestTones: string[] = [] + for (const tone of ['ZQ32', targetTone, 'PQ']) { + if (!requestTones.includes(tone)) requestTones.push(tone) + } + + const responses: ListenResponse[] = [] + let fetchErr: Error | null = null + for (const tone of requestTones) { + try { + responses.push(await fetchListenInfo(contentId, copyrightId, songId, albumId, rt, tone)) + } catch (err: any) { + if (!fetchErr) fetchErr = err + } + } + if (!responses.length) throw fetchErr ?? new Error('migu listen-url 全部失败') + + const expectedSize = Number(songInfo?.size ?? 0) || 0 + const candidates = buildCandidates(responses, expectedSize) + let probed = 0 + for (const candidate of candidates) { + if (probed++ >= MAX_PROBES) break + if (await downloadInfoValid(candidate)) { + const quality = TONE_QUALITY[candidate.format] ?? type + return { + type: quality, + url: candidate.url, + ...(candidate.encrypted ? { encrypted: true, fileKey: candidate.fileKey } : {}), + ext: candidate.ext || miguFormatExt(candidate.format), + size: candidate.size, + } + } + } + + // 全部候选不可用:优先回传接口文案(如「登录后免费试听」) + const dialog = responses.map(r => String(r?.data?.dialogInfo?.text ?? '').trim()).find(Boolean) + if (dialog) throw new Error(`migu 无可用音频:${dialog}(登录后可解锁更高档位)`) + if (fetchErr) throw fetchErr + throw new Error('migu 未返回可访问的音频档位') + })() + return requestObj +} + +export default { getMusicUrl, supportQualitys } diff --git a/sdk/apis/qian.ts b/sdk/apis/qian.ts new file mode 100644 index 0000000..3c248aa --- /dev/null +++ b/sdk/apis/qian.ts @@ -0,0 +1,73 @@ +// 千千音乐 URL 获取(/v1/song/tracklink,签名通道) +// 档位:3000=flac / 320 / 128 / 64(aac)。实测(2026-10-02): +// - 免费曲目(搜索 isVip=0,如 SSR Beats 特辑)各档位均返回明文直链 data.path(audio0x.dmhmusic.com,CDN 签名 URL) +// - VIP/版权曲目返回 200/errno 22000 但 data 无 path、无 trail_audio_info,需登录态带会员 cookie 才可能有直链 +// 契约:getMusicUrl(songInfo, type) → { promise, cancelHttp },promise resolve { type, url }(type 为实际命中档位) +import { apiOk, firstNonEmpty, trackLink } from '../qian/util.ts' + +// 请求档位 → tracklink rate 尝试链(优先请求档,逐级降级;与上游 3000→320→128→64 的探测顺序一致) +const QUALITY_RATES: Record = { + '64k': ['64'], + '128k': ['128', '64'], + '320k': ['320', '128', '64'], + 'flac': ['3000', '320', '128', '64'], + 'flac24bit': ['3000', '320', '128', '64'], +} +const DEFAULT_RATES = ['3000', '320', '128', '64'] + +export const supportQualitys = ['64k', '128k', '320k', 'flac'] + +/** 命中档位判定:以响应 format/rate/bits 为准(可能低于请求档位) */ +const typeOfHit = (requestRate: string, data: any): string => { + const format = String(data?.format ?? '').toLowerCase() + const rate = Number(data?.rate ?? 0) + const bits = Number(data?.bits ?? 0) + if (format === 'flac' || rate >= 1000) return bits === 24 ? 'flac24bit' : 'flac' + if (rate >= 320 || requestRate === '320') return '320k' + if (rate >= 128 || requestRate === '128') return '128k' + return '64k' +} + +/** 逐档请求 tracklink,取首个带 path 的档位;register 用于取流对象取消当前请求 */ +export const getDownloadUrl = async(tsid: string, type?: string, register?: (req: any) => void) => { + const id = String(tsid ?? '').trim() + if (!id) throw new Error('千千歌曲 id(TSID)为空') + const rates = QUALITY_RATES[String(type ?? '')] ?? DEFAULT_RATES + + let lastErr: any = null + for (const rate of rates) { + try { + const body = await trackLink(id, rate, register) + if (!apiOk(body)) { + lastErr = new Error(body?.errmsg || `千千取流失败(errno ${body?.errno})`) + continue + } + const data = body?.data ?? {} + const url = firstNonEmpty(data?.path, data?.trail_audio_info?.path) + if (url) return { type: typeOfHit(rate, data), url } + lastErr = new Error('千千该歌曲无可用直链(VIP/版权曲目需登录态,免费曲目可直接取流)') + } catch (err) { + lastErr = err + } + } + throw lastErr ?? new Error('千千未找到可用音频直链') +} + +export const getMusicUrl = (songInfo: any, type?: string) => { + const requestObj: any = { promise: null, cancelHttp() {} } + let current: any = null + requestObj.cancelHttp = () => { + current?.cancelHttp?.() + current = null + } + requestObj.promise = (async() => { + // 上游 GetDownloadURL:Song.URL 已有直链时直接复用(Parse 场景) + if (songInfo?.url) return { type: type || '128k', url: String(songInfo.url) } + const tsid = firstNonEmpty(songInfo?.songmid, songInfo?.TSID) + if (!tsid) throw new Error('千千歌曲 id(TSID)为空') + return getDownloadUrl(tsid, type, (req) => { current = req }) + })() + return requestObj +} + +export default { getMusicUrl, getDownloadUrl, supportQualitys } diff --git a/sdk/apple/album.ts b/sdk/apple/album.ts new file mode 100644 index 0000000..48cbee2 --- /dev/null +++ b/sdk/apple/album.ts @@ -0,0 +1,77 @@ +// Apple Music 专辑(上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs) +// 详情:amp-api /v1/catalog//albums/?extend=extendedAssetUrls +// → data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进) +// 链接:extractAppleID(link, 'album'),支持裸数字 id 与 /us/album// 链接。 +import { + appleAmpGet, + appleAlbumToPlaylist, + appleSongFromCatalog, + extractAppleID, + fetchAllTrackResources, + getAppleConfig, +} from './util.ts' + +export { searchAlbum } from './musicSearch.ts' + +import { searchAlbum } from './musicSearch.ts' + +/** 专辑详情(page/limit 在完整曲目上切片,与其它平台 getAlbumDetail 契约一致) */ +export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => { + const albumId = extractAppleID(String(rawId ?? ''), 'album') + if (!albumId) throw new Error(`invalid apple music album id/link: ${rawId}`) + + const cfg = getAppleConfig() + const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/albums/${albumId}`, { extend: 'extendedAssetUrls' }) + const album = (body?.data ?? [])[0] + if (!album) throw new Error(`apple album not found: ${albumId}`) + + const tracks = await fetchAllTrackResources(album?.relationships?.tracks) + const songs = tracks.map((track: any) => appleSongFromCatalog(track, albumId)) + + const p = Math.max(1, Number(page) || 1) + const l = Math.max(1, Number(limit) || 50) + const summary = appleAlbumToPlaylist(album) + // 上游 fetchPlaylistDetail 的做法:接口未给 trackCount 时以实际曲目数补齐 + if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length + + return { + source: 'apple' as const, + info: { + id: albumId, + name: summary.name, + img: summary.cover, + desc: summary.description, + author: summary.creator, + link: summary.link, + trackCount: summary.trackCount, + releaseDate: summary.extra?.release_date || '', + recordLabel: summary.extra?.record_label || '', + }, + list: songs.slice((p - 1) * l, (p - 1) * l + l), + page: p, + limit: l, + total: songs.length, + } +} + +/** 上游 GetAlbumSongs:只返回曲目数组 */ +export const getAlbumSongs = async(id: string): Promise => { + const { list } = await getAlbumDetail(id, 1, 100000) + return list +} + +/** 上游 ParseAlbum:专辑链接 → 详情(曲目全量,不分页) */ +export const parseAlbumLink = async(link: string) => { + const detail = await getAlbumDetail(link, 1, 100000) + return { + source: 'apple' as const, + id: detail.info.id, + info: detail.info, + list: detail.list, + page: 1, + limit: Math.max(detail.total, 1), + total: detail.total, + } +} + +export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbumLink } diff --git a/sdk/apple/index.ts b/sdk/apple/index.ts new file mode 100644 index 0000000..2b5b561 --- /dev/null +++ b/sdk/apple/index.ts @@ -0,0 +1,106 @@ +// Apple Music 平台模块(上游 music-lib/apple/apple.go 的 Node/TS 移植,平台 id: apple) +// 能力:搜索 / 歌词(TTML,匿名 developer token 通常拿不到,见 lyric.ts)/ 封面 / +// 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+分类歌单+链接解析)/ +// 链接解析(song/album/playlist)/ 取流(**仅 preview 试听片段**,见 sdk/apis/apple.ts)。 +// 凭据:上游 New(cookie) → getCredentials('apple'),cookie 形如 +// "media-user-token=xxx;storefront=cn;token=xxx",也支持 storefront/mediaUserToken/token 直填字段。 +// 未实现(上游也没有):userPlaylist、榜单、热搜、歌手、评论、扫码登录。 +import appleApi from '../apis/apple.ts' +import musicSearch from './musicSearch.ts' +import lyric from './lyric.ts' +import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import { + appleArtworkURL, + appleSongFromCatalog, + appleWebURL, + extractAppleID, + fetchAppleSongResource, + getAppleConfig, + getAppleToken, +} from './util.ts' +import { getCredentials } from '../auth/store.ts' + +const songIdOf = (songInfo: any): string => ( + extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song') +) + +const apple = { + /** 预热 developer token(失败不阻塞启动,路由调用时会重试) */ + init() { + return getAppleToken().then(() => true).catch(() => false) + }, + + musicSearch, + + getMusicUrl(songInfo, type) { + return appleApi.getMusicUrl(songInfo, type) + }, + + getLyric(songInfo, ...rest) { + return lyric.getLyric(songInfo, ...rest) + }, + + /** 封面:搜索结果自带 img;缺失时回源歌曲详情(best-effort) */ + async getPic(songInfo) { + if (songInfo?.img) return String(songInfo.img) + const id = songIdOf(songInfo) + if (!id) return null + try { + const res = await fetchAppleSongResource(id) + return res ? (appleArtworkURL(res.attributes?.artwork, 600) || null) : null + } catch { + return null + } + }, + + /** 歌曲详情(best-effort;等价上游 Parse(id)) */ + async getMusicInfo(songInfo) { + const id = songIdOf(songInfo) + if (!id) return null + const res = await fetchAppleSongResource(id, 'lyrics,albums') + return res ? appleSongFromCatalog(res) : null + }, + + album: { + getAlbumDetail: album.getAlbumDetail, + getAlbumSongs: album.getAlbumSongs, + searchAlbum: album.searchAlbum, + parseAlbumLink: album.parseAlbumLink, + }, + + playlist, + + // 契约别名:Apple 的歌单分类即 genre curators,挂到 songList 键供 /song-list/apple 复用 + songList: { + sortList: [{ name: '热门', id: '1526756058' }], + getTags() { + return playlist.getCategories() + }, + getList(sortId: string, tagId: string, page = 1, limit = 20) { + const cate = String(tagId || sortId || '').trim() + return playlist.getCategoryPlaylists(cate, page, limit) + }, + }, + + parse, + + getMusicDetailPageUrl(songInfo) { + if (songInfo?.link) return String(songInfo.link) + const id = songIdOf(songInfo) + if (!id) return 'https://music.apple.com/' + return appleWebURL('song', id, getAppleConfig().storefront) + }, +} + +/** 登录状态(media-user-token 存在性判断;无在线校验接口) */ +export const status = () => { + const cred = getCredentials('apple') as any + const cfg = getAppleConfig() + return (cfg.mediaUserToken || cred?.cookie) + ? { logged: true, updatedAt: cred?.updatedAt } + : { logged: false } +} + +export default apple diff --git a/sdk/apple/lyric.ts b/sdk/apple/lyric.ts new file mode 100644 index 0000000..5582363 --- /dev/null +++ b/sdk/apple/lyric.ts @@ -0,0 +1,50 @@ +// Apple Music 歌词(上游 apple.go GetLyrics) +// 通道:amp-api /v1/catalog//songs/?include=lyrics&extend=extendedAssetUrls +// → data[0].relationships.lyrics.data[*].attributes.ttml +// 上游取到的是 TTML(Apple 逐字歌词格式,非 LRC),原样放入 lyric 字段返回;没有则忠实返回空串。 +// 注意(2026-10 实测):匿名 developer token 请求时 Apple 不返回 lyrics relationship +// (即使 attributes.hasLyrics=true,歌单/搜索接口也一样),歌词接口需要 media-user-token(订阅态)才能拿到; +// 本实现保持上游行为:拿不到就返回 { lyric: '', tlyric: '' },不抛错。 +// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric } +import { appleAmpGetTracked, extractAppleID, getAppleConfig } from './util.ts' + +export const getLyric = (songInfo: any, ..._rest: any[]) => { + const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} } + const tracker = { current: null as any } + requestObj.cancelHttp = () => { + try { + tracker.current?.cancelHttp?.() + } catch { + // ignore + } + tracker.current = null + } + + const songId = extractAppleID(String(songInfo?.songmid ?? songInfo?.id ?? ''), 'song') + + requestObj.promise = (async() => { + if (!songId) throw new Error('apple 歌曲 id 无效') + const cfg = getAppleConfig() + const body: any = await appleAmpGetTracked( + `/v1/catalog/${cfg.storefront}/songs/${songId}`, + { include: 'lyrics', extend: 'extendedAssetUrls' }, + tracker, + ) + const lyrics = body?.data?.[0]?.relationships?.lyrics?.data ?? [] + for (const rel of lyrics) { + const text = String(rel?.attributes?.ttml ?? '') + if (text) return { lyric: text, tlyric: '' } + } + return { lyric: '', tlyric: '' } + })() + + return requestObj +} + +/** 非取消句柄版:直接返回歌词文本(内部/测试用) */ +export const getLyricsText = async(songId: string): Promise => { + const result = await getLyric({ songmid: songId }).promise + return result?.lyric || '' +} + +export default { getLyric } diff --git a/sdk/apple/musicSearch.ts b/sdk/apple/musicSearch.ts new file mode 100644 index 0000000..6750b14 --- /dev/null +++ b/sdk/apple/musicSearch.ts @@ -0,0 +1,74 @@ +// Apple Music 搜索(上游 apple.go 的 Search / SearchAlbum / SearchPlaylist) +// 通道:amp-api /v1/catalog//search(Bearer developer token,见 util.ts) +// 单次请求上限 25 条,page/limit 用 offset 翻页;响应没有 total, +// 因此 total = 已翻过的条数 + 本页条数(下一页是否存在由 next 决定,allPage=页码+1)。 +import { + appleAmpGet, + appleSongFromCatalog, + appleAlbumToPlaylist, + applePlaylistToPlaylist, + getAppleConfig, +} from './util.ts' + +/** amp-api search 单页硬上限 */ +export const SEARCH_PAGE_MAX = 25 + +const catalogSearch = async(keyword: string, types: string, page: number, limit: number) => { + const word = String(keyword ?? '').trim() + const p = Math.max(1, Number(page) || 1) + const l = Math.min(Math.max(1, Number(limit) || 20), 100) + if (!word) return { body: null, page: p, limit: l } + const cfg = getAppleConfig() + const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/search`, { + term: word, + types, + limit: Math.min(l, SEARCH_PAGE_MAX), + offset: (p - 1) * l, + }) + return { body, page: p, limit: l } +} + +/** 上游 Search:搜索歌曲 */ +export const search = async(keyword: string, page = 1, limit = 20) => { + const { body, page: p, limit: l } = await catalogSearch(keyword, 'songs', page, limit) + const bucket = body?.results?.songs ?? {} + const list = (bucket.data ?? []).map((item: any) => appleSongFromCatalog(item)) + const hasNext = Boolean(bucket.next) + return { + list, + allPage: hasNext ? p + 1 : p, + total: (p - 1) * l + list.length, + limit: l, + source: 'apple' as const, + } +} + +/** 上游 SearchAlbum:搜索专辑(返回歌单摘要形状) */ +export const searchAlbum = async(keyword: string, page = 1, limit = 20) => { + const { body, page: p, limit: l } = await catalogSearch(keyword, 'albums', page, limit) + const items = body?.results?.albums?.data ?? [] + const list = items.map((item: any) => appleAlbumToPlaylist(item)) + return { + list, + total: (p - 1) * l + list.length, + page: p, + limit: l, + source: 'apple' as const, + } +} + +/** 上游 SearchPlaylist:搜索歌单 */ +export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => { + const { body, page: p, limit: l } = await catalogSearch(keyword, 'playlists', page, limit) + const items = body?.results?.playlists?.data ?? [] + const list = items.map((item: any) => applePlaylistToPlaylist(item)) + return { + list, + total: (p - 1) * l + list.length, + page: p, + limit: l, + source: 'apple' as const, + } +} + +export default { search, searchAlbum, searchPlaylist } diff --git a/sdk/apple/parse.ts b/sdk/apple/parse.ts new file mode 100644 index 0000000..efb54c9 --- /dev/null +++ b/sdk/apple/parse.ts @@ -0,0 +1,22 @@ +// Apple Music 链接解析(上游 apple.go Parse / ParseAlbum / ParsePlaylist) +// song: https://music.apple.com/us/album//?i=(?i= 优先)或裸数字 song id +// album: https://music.apple.com/us/album// 或裸数字 album id +// playlist: https://music.apple.com/us/playlist//pl.xxxx 或裸 pl.* id +// 上游 Parse 会请求歌曲详情(include=lyrics,albums)并填入 preview URL。 +import { appleSongFromCatalog, extractAppleID, fetchAppleSongResource } from './util.ts' +import { parseAlbumLink } from './album.ts' +import { parsePlaylistLink } from './playlist.ts' + +/** 上游 Parse:单曲链接/裸 id → Song(含 previewUrl 试听直链) */ +export const parseSong = async(link: string) => { + const songId = extractAppleID(String(link ?? ''), 'song') + if (!songId) throw new Error(`invalid apple music song link: ${link}`) + const res = await fetchAppleSongResource(songId, 'lyrics,albums') + if (!res) throw new Error(`apple song not found: ${songId}`) + return appleSongFromCatalog(res) +} + +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/apple/playlist.ts b/sdk/apple/playlist.ts new file mode 100644 index 0000000..5f1602c --- /dev/null +++ b/sdk/apple/playlist.ts @@ -0,0 +1,165 @@ +// Apple Music 歌单(上游 apple.go 的 SearchPlaylist / ParsePlaylist / GetPlaylistSongs / +// GetPlaylistCategories / GetCategoryPlaylists 与 applePlaylistToPlaylist 映射) +// 详情:amp-api /v1/catalog//playlists/?limit[tracks]=300&extend=extendedAssetUrls +// 曲目超过一页时沿 relationships.tracks.next 全量拉取(上游 fetchAllTrackResources,带 20 页上限防死循环)。 +// 分类:上游使用写死的 Apple Music genre curator 列表(appleCuratorCategories), +// GetCategoryPlaylists 固定 storefront=cn + l=zh-Hans-CN(curator id 取自 cn 区)。 +// 链接:extractAppleID(link, 'playlist'),支持 pl.* 裸 id 与 /us/playlist//pl.* 链接。 +import { + appleAmpGet, + applePlaylistToPlaylist, + appleSongFromCatalog, + extractAppleID, + fetchAllTrackResources, + getAppleConfig, +} from './util.ts' + +export { searchPlaylist } from './musicSearch.ts' + +import { searchPlaylist } from './musicSearch.ts' + +/** 上游 appleCuratorCategories(从公开搜索落地页提取的 Apple Music genre curators) */ +export const appleCuratorCategories: Array<{ id: string, name: string }> = [ + { id: '1526756058', name: '热门' }, + { id: '1479949880', name: 'C-Pop' }, + { id: '1019400042', name: '国语流行' }, + { id: '1019398918', name: '粤语流行' }, + { id: '1019399540', name: '国际流行' }, + { id: '1019399551', name: 'K-Pop' }, + { id: '1019399547', name: 'J-Pop' }, + { id: '989061415', name: '嘻哈/说唱' }, + { id: '1019400044', name: 'R&B' }, + { id: '1019400046', name: '摇滚' }, + { id: '1019397973', name: '另类音乐' }, + { id: '976439535', name: '舞曲' }, + { id: '976439536', name: '电子' }, + { id: '976439541', name: '独立音乐' }, + { id: '1019399549', name: '爵士乐' }, + { id: '1019398924', name: '古典音乐' }, + { id: '976439528', name: '蓝调' }, + { id: '976439534', name: '乡村音乐' }, + { id: '1531542847', name: '拉丁音乐' }, + { id: '988656348', name: '非洲音乐' }, + { id: '976439543', name: '金属乐' }, + { id: '976439550', name: '朋克乐' }, + { id: '1019400049', name: '不插电' }, + { id: '1231181168', name: '影视原声' }, + { id: '1441811365', name: 'DJ 混音精选' }, + { id: '1532467784', name: '瞩目之星' }, + { id: '1554938339', name: '年代之声' }, + { id: '1564180390', name: '空间音频' }, + { id: '989010186', name: '亲子' }, +] + +/** 上游 GetPlaylistCategories:静态返回 Apple Music genre curators */ +export const getCategories = async() => ({ + source: 'apple' as const, + list: appleCuratorCategories.map(item => ({ id: item.id, name: item.name, group: 'Apple Music 分类', source: 'apple' as const })), +}) + +/** 上游 GetCategoryPlaylists:按 curator 分类取歌单(cn storefront,单请求上限 25,循环补齐) */ +export const getCategoryPlaylists = async(categoryID: string, page = 1, limit = 20) => { + const categoryId = String(categoryID ?? '').trim() + if (!categoryId) throw new Error('apple 分类 id 不能为空') + const p = Math.max(1, Number(page) || 1) + const l = Math.min(Math.max(1, Number(limit) || 20), 300) + const apiMax = 25 + let offset = (p - 1) * l + const all: any[] = [] + + while (all.length < l) { + const batchSize = Math.min(apiMax, l - all.length) + let body: any + try { + body = await appleAmpGet(`/v1/catalog/cn/apple-curators/${categoryId}/playlists`, { + limit: batchSize, + offset, + l: 'zh-Hans-CN', + }) + } catch (err) { + if (all.length > 0) break // 上游:已有数据时返回已取部分 + throw err + } + const data = body?.data ?? [] + all.push(...data.map((item: any) => applePlaylistToPlaylist(item))) + // 没有更多数据或没有下一页 + if (data.length < batchSize || !body?.next) break + offset += data.length + } + + return { + source: 'apple' as const, + list: all.slice(0, l), + page: p, + limit: l, + total: all.length, + } +} + +/** 歌单详情(全量曲目后按 page/limit 切片) */ +export const getPlaylistDetail = async(rawId: string, page = 1, limit = 100) => { + const playlistId = extractAppleID(String(rawId ?? ''), 'playlist') + if (!playlistId) throw new Error(`invalid apple music playlist id/link: ${rawId}`) + + const cfg = getAppleConfig() + const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/playlists/${playlistId}`, { + 'limit[tracks]': 300, + extend: 'extendedAssetUrls', + }) + const item = (body?.data ?? [])[0] + if (!item) throw new Error(`apple playlist not found: ${playlistId}`) + + const tracks = await fetchAllTrackResources(item?.relationships?.tracks) + const songs = tracks.map((track: any) => appleSongFromCatalog(track)) + + const p = Math.max(1, Number(page) || 1) + const l = Math.max(1, Number(limit) || 100) + const summary = applePlaylistToPlaylist(item) + if (!summary.trackCount || songs.length > summary.trackCount) summary.trackCount = songs.length + + return { + source: 'apple' as const, + info: { + id: playlistId, + name: summary.name, + img: summary.cover, + desc: summary.description, + author: summary.creator, + link: summary.link, + trackCount: summary.trackCount, + }, + list: songs.slice((p - 1) * l, (p - 1) * l + l), + page: p, + limit: l, + total: songs.length, + } +} + +/** 上游 GetPlaylistSongs:只返回曲目数组 */ +export const getPlaylistSongs = async(id: string): Promise => { + const { list } = await getPlaylistDetail(id, 1, 100000) + return list +} + +/** 上游 ParsePlaylist:歌单链接 → 详情(曲目全量,不分页) */ +export const parsePlaylistLink = async(link: string) => { + const detail = await getPlaylistDetail(link, 1, 100000) + return { + source: 'apple' as const, + id: detail.info.id, + info: detail.info, + list: detail.list, + page: 1, + limit: Math.max(detail.total, 1), + total: detail.total, + } +} + +export default { + searchPlaylist, + getPlaylistDetail, + getPlaylistSongs, + getCategories, + getCategoryPlaylists, + parsePlaylistLink, +} diff --git a/sdk/apple/util.ts b/sdk/apple/util.ts new file mode 100644 index 0000000..edc1271 --- /dev/null +++ b/sdk/apple/util.ts @@ -0,0 +1,452 @@ +// Apple Music(music.apple.com / amp-api.music.apple.com)公共工具 +// 上游 music-lib/apple/apple.go 的移植基础层: +// - New(cookie) → getCredentials('apple'):cookie 支持 "media-user-token=xxx;storefront=cn;token=xxx" +// - fetchAppleToken():从 music.apple.com 首页 → index-legacy JS 抓 developer token(amp-api 的 Bearer) +// 当前(2026-10)Apple 下发的 JWT 以 eyJ0eXAi({"typ":...})开头而非上游正则写的 eyJh, +// 故这里泛化为任意 JWT(eyJ…)并优先选择 issuer=AMPWebPlay 的 token;带回退与失效重取见 getAppleToken/invalidateAppleToken。 +// - ampGet/ampHeaders:amp-api 请求(httpFetch 不自动跟 302,首页跳转用 fetchFollow 手动跟) +// - 模型转换:appleSongFromCatalog / appleAlbumToPlaylist / applePlaylistToPlaylist(字段映射同上游) +// - ID 提取:extractAppleID / extractAppleIDFromPath / isAppleRawID(apple_test.go 的断言全部逐条保留) +import { httpFetch } from '../request.ts' +import { formatPlayTime } from '../common/utils.ts' +import { getCredentials } from '../auth/store.ts' + +export const APPLE_HOMEPAGE_URL = 'https://music.apple.com' +export const APPLE_AMP_API_URL = 'https://amp-api.music.apple.com' +export const DEFAULT_STOREFRONT = 'us' +export const APPLE_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36' + +/** 上游 New(cookie) 的等价配置(cookie 字符串解析 + 凭据直填字段) */ +export interface AppleRuntimeConfig { + storefront: string + /** media-user-token(订阅用户 token;歌词/完整曲目等受限资源需要) */ + mediaUserToken: string + /** 显式提供的 developer token(上游 cookie 里的 token=),优先于网页抓取 */ + token: string +} + +/** 上游 parseCookieParts:按 ';' 拆分 cookie,'k=v' 去空白 */ +export const parseCookieParts = (cookie: string): Record => { + const result: Record = {} + for (const part of String(cookie ?? '').split(';')) { + const trimmed = part.trim() + const idx = trimmed.indexOf('=') + if (idx > 0) { + const key = trimmed.slice(0, idx).trim() + const val = trimmed.slice(idx + 1).trim() + result[key] = val + } + } + return result +} + +/** 读取 apple 平台运行时配置(对应上游 New(cookie)) */ +export const getAppleConfig = (): AppleRuntimeConfig => { + const cred = (getCredentials('apple') as any) || {} + const parts = parseCookieParts(String(cred.cookie ?? '')) + const storefront = String(cred.storefront || parts.storefront || DEFAULT_STOREFRONT).trim() + return { + storefront: storefront || DEFAULT_STOREFRONT, + mediaUserToken: String(cred.mediaUserToken || parts['media-user-token'] || '').trim(), + token: String(cred.token || parts.token || '').trim(), + } +} + +// ---------------- developer token ---------------- + +let cachedToken = '' +let cachedAt = 0 +let pendingToken: Promise | null = null +// 显式配置的 token 在 401/403 后不再优先使用,改为回源网页抓取 +let bypassExplicitToken = false +// JWT 自身带 exp(通常数月),这里再加 12h 进程内缓存,避免频繁拉取数 MB 的 JS bundle +const TOKEN_CACHE_TTL = 12 * 60 * 60 * 1000 + +/** 解码 JWT payload(Apple token 为 ES256 JWT) */ +export const decodeAppleTokenPayload = (token: string): any => { + try { + const part = String(token ?? '').split('.')[1] || '' + const json = Buffer.from(part.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8') + return JSON.parse(json) + } catch { + return {} + } +} + +export const isAppleTokenExpired = (token: string): boolean => { + const exp = Number(decodeAppleTokenPayload(token)?.exp || 0) + return exp > 0 && exp * 1000 < Date.now() + 60 * 1000 +} + +/** 从 index JS 中提取所有 JWT 形态的候选 token(含引号/等号前缀的主匹配 + 无前缀回退) */ +export const extractAppleTokens = (js: string): string[] => { + const pattern = '(eyJ[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,})' + const found: string[] = [] + let m: RegExpExecArray | null + const primary = new RegExp(`(?:=["'])${pattern}`, 'g') + while ((m = primary.exec(String(js ?? '')))) found.push(m[1]) + if (!found.length) { + const loose = new RegExp(pattern, 'g') + while ((m = loose.exec(String(js ?? '')))) found.push(m[1]) + } + return [...new Set(found)] +} + +/** 选择可用 token:优先 issuer=AMPWebPlay(amp-api 使用的那个),其次第一个未过期;全部过期视为抓取失败 */ +export const pickAppleToken = (js: string): string => { + const tokens = extractAppleTokens(js) + if (!tokens.length) return '' + const alive = tokens.filter(t => !isAppleTokenExpired(t)) + if (!alive.length) return '' + const preferred = alive.find(t => String(decodeAppleTokenPayload(t)?.iss || '') === 'AMPWebPlay') + return preferred || alive[0] || '' +} + +/** 手动跟随 3xx(httpFetch 不自动跟跳;Apple 首页 → /cn → /cn/new 有两跳) */ +export const fetchFollow = async(url: string, headers: Record = {}, maxHops = 5): Promise => { + let current = String(url) + for (let hop = 0; hop < maxHops; hop++) { + const res: any = await httpFetch(current, { headers, timeout: 20000 }).promise + const code = Number(res?.statusCode) || 0 + const location = res?.headers?.location + if (code >= 300 && code < 400 && location) { + current = new URL(String(location), current).href + continue + } + return res + } + throw new Error('apple: too many redirects') +} + +const responseText = (res: any): string => { + if (typeof res?.body === 'string') return res.body + return Buffer.isBuffer(res?.raw) ? res.raw.toString('utf8') : String(res?.body ?? '') +} + +/** 上游 fetchAppleToken:首页 → index-legacy JS → bearer token */ +export const fetchAppleToken = async(): Promise => { + const home = await fetchFollow(APPLE_HOMEPAGE_URL, { 'User-Agent': APPLE_UA }) + if (Number(home?.statusCode) !== 200) throw new Error(`apple: fetch homepage error: HTTP ${home?.statusCode}`) + const html = responseText(home) + + // 上游:/(assets/index-legacy[~\-][^/"]+\.js);当前构建为 index-legacy~hash.js(回退主入口 index~hash.js) + let match = /\/(assets\/index-legacy[~-][^/"']+\.js)/.exec(html) + if (!match) match = /\/(assets\/index[~-][^/"']+\.js)/.exec(html) + if (!match) throw new Error('apple: index.js URI not found') + + const jsRes = await fetchFollow(`${APPLE_HOMEPAGE_URL}/${match[1]}`, { 'User-Agent': APPLE_UA }) + if (Number(jsRes?.statusCode) !== 200) throw new Error(`apple: fetch index.js error: HTTP ${jsRes?.statusCode}`) + const token = pickAppleToken(responseText(jsRes)) + if (!token) throw new Error('apple: token not found in index.js') + return token +} + +/** + * 获取 developer token(缓存 + JWT exp 校验 + 并发去重) + * force=true 或显式 token 失效(401/403 后)时回源重新抓取 + */ +export const getAppleToken = async(force = false): Promise => { + const cfg = getAppleConfig() + if (!force && !bypassExplicitToken && cfg.token) return cfg.token + if (!force && cachedToken && Date.now() - cachedAt < TOKEN_CACHE_TTL && !isAppleTokenExpired(cachedToken)) { + return cachedToken + } + if (!pendingToken) { + pendingToken = (async() => { + try { + const token = await fetchAppleToken() + cachedToken = token + cachedAt = Date.now() + return token + } finally { + pendingToken = null + } + })() + } + return pendingToken +} + +/** 清除缓存 token(401/403 时调用,下次请求会回源重取) */ +export const invalidateAppleToken = (): void => { + cachedToken = '' + cachedAt = 0 + bypassExplicitToken = true +} + +/** 仅测试/调试用:重置内存态 */ +export const resetAppleTokenState = (): void => { + cachedToken = '' + cachedAt = 0 + bypassExplicitToken = false + pendingToken = null +} + +// ---------------- amp-api 请求 ---------------- + +export const appleAmpHeaders = (token: string): Record => { + const cfg = getAppleConfig() + const headers: Record = { + Authorization: `Bearer ${token}`, + Origin: APPLE_HOMEPAGE_URL, + Referer: `${APPLE_HOMEPAGE_URL}/`, + 'User-Agent': APPLE_UA, + Accept: 'application/json', + } + // 上游 ampHeaders:cookie 中的 media-user-token 作为 Cookie 携带 + if (cfg.mediaUserToken) headers.Cookie = `media-user-token=${cfg.mediaUserToken}` + return headers +} + +/** 拼接 amp-api URL:支持以 '/' 开头的路径与 next 返回的完整 URL */ +export const buildAppleAmpUrl = (uri: string, params?: Record | URLSearchParams | null): string => { + const raw = String(uri ?? '') + const base = /^https?:\/\//i.test(raw) + ? raw + : raw.startsWith('/') + ? APPLE_AMP_API_URL + raw + : `${APPLE_AMP_API_URL}/${raw}` + let query = '' + if (params instanceof URLSearchParams) { + query = params.toString() + } else if (params && typeof params === 'object') { + const search = new URLSearchParams() + for (const key of Object.keys(params)) { + const value = params[key] + if (value == null || value === '') continue + search.append(key, String(value)) + } + query = search.toString() + } + if (!query) return base + return base + (base.includes('?') ? '&' : '?') + query +} + +const ampErrorDetail = (res: any): string => { + const first = res?.body?.errors?.[0] + return String(first?.detail || first?.title || '') +} + +/** + * amp-api GET(带 token 失效重取:401/403 时清缓存重试一次) + * tracker 用于把内层 httpFetch 请求对象暴露给调用方做 cancelHttp + */ +export const appleAmpGetTracked = async( + uri: string, + params: Record | URLSearchParams | null | undefined, + tracker?: { current: any }, +): Promise => { + const url = buildAppleAmpUrl(uri, params) + const attempt = async(): Promise => { + const token = await getAppleToken() + const request = httpFetch(url, { headers: appleAmpHeaders(token), timeout: 20000 }) + if (tracker) tracker.current = request + return request.promise + } + + let res: any = await attempt() + if (res?.statusCode === 401 || res?.statusCode === 403) { + invalidateAppleToken() + res = await attempt() + } + if (!(res?.statusCode >= 200 && res?.statusCode < 300)) { + const detail = ampErrorDetail(res) + throw new Error(`apple amp-api HTTP ${res?.statusCode}${detail ? `: ${detail}` : ''}`) + } + return res.body +} + +export const appleAmpGet = async(uri: string, params?: Record | null): Promise => ( + appleAmpGetTracked(uri, params, { current: null }) +) + +/** 拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources;带页数上限防死循环) */ +const MAX_TRACK_PAGES = 20 +export const fetchAllTrackResources = async(tracks: any): Promise => { + const all: any[] = [...(tracks?.data ?? [])] + let nextUri = String(tracks?.next ?? '').trim() + for (let page = 0; nextUri && page < MAX_TRACK_PAGES; page++) { + const body: any = await appleAmpGetTracked(nextUri, null, undefined) + all.push(...(body?.data ?? [])) + nextUri = String(body?.next ?? '').trim() + } + return all +} + +// ---------------- 模型转换(字段映射同上游) ---------------- + +/** 上游 appleArtwork.CoverURL(size):替换 {w}/{h} 占位 */ +export const appleArtworkURL = (artwork: any, size = 600): string => { + const url = String(artwork?.url ?? '') + if (!url) return '' + return url.replace(/\{w\}/g, String(size)).replace(/\{h\}/g, String(size)) +} + +/** 上游 appleSongFromCatalog → 契约 Song 形状 */ +export const appleSongFromCatalog = (res: any, defaultAlbumId = ''): any => { + const attr = res?.attributes ?? {} + const previewURL = Array.isArray(attr.previews) && attr.previews.length ? String(attr.previews[0]?.url ?? '') : '' + const durationSec = Math.round(Number(attr.durationInMillis ?? 0) / 1000) + return { + songmid: String(res?.id ?? ''), + name: attr.name || '', + singer: attr.artistName || '', + albumId: String(attr.albumId || defaultAlbumId || ''), + albumName: attr.albumName || '', + interval: formatPlayTime(durationSec), + img: appleArtworkURL(attr.artwork, 600) || null, + lrc: null, + types: [], + _types: {}, + source: 'apple' as const, + link: attr.url || '', // music.apple.com 网页地址 + previewUrl: previewURL, // 30-90 秒试听片段直链(Apple 不提供完整曲目明文流) + duration: durationSec, + isrc: attr.isrc || '', + } +} + +/** 上游 appleAlbumToPlaylist → 契约歌单/专辑摘要 */ +export const appleAlbumToPlaylist = (res: any): any => { + const attr = res?.attributes ?? {} + return { + id: String(res?.id ?? ''), + name: attr.name || '', + cover: appleArtworkURL(attr.artwork, 300), + trackCount: Number(attr.trackCount ?? 0) || 0, + creator: attr.artistName || '', + description: attr.editorialNotes?.short || '', + link: attr.url || '', + source: 'apple' as const, + extra: { + release_date: attr.releaseDate || '', + record_label: attr.recordLabel || '', + }, + } +} + +/** 上游 applePlaylistToPlaylist → 契约歌单摘要(description 四级回退同上游) */ +export const applePlaylistToPlaylist = (res: any): any => { + const attr = res?.attributes ?? {} + const description = attr.description?.short + || attr.description?.standard + || attr.editorialNotes?.short + || attr.editorialNotes?.standard + || '' + return { + id: String(res?.id ?? ''), + name: attr.name || '', + cover: appleArtworkURL(attr.artwork, 300), + trackCount: Number(attr.trackCount ?? 0) || 0, + creator: attr.curatorName || '', + description, + link: attr.url || '', + source: 'apple' as const, + } +} + +/** 网页地址(搜索结果 link 缺失时的兜底) */ +export const appleWebURL = (kind: 'song' | 'album' | 'playlist', id: string, storefront = DEFAULT_STOREFRONT): string => { + const sf = String(storefront || DEFAULT_STOREFRONT) + return `${APPLE_HOMEPAGE_URL}/${sf}/${kind}/${id}` +} + +// ---------------- ID 提取(上游 extractAppleID/isAppleRawID/extractAppleIDFromPath) ---------------- + +/** 上游 isAppleRawID:裸 id 判定(playlist 支持 pl.* / 字母数字,其余要求纯数字) */ +export const isAppleRawID = (value: string, mediaType: string): boolean => { + if (/[/?#]/.test(value) || value.includes('://')) return false + if (mediaType === 'playlist') { + if (value.startsWith('pl.')) return /^[a-zA-Z0-9._-]+$/.test(value) + return /^[a-zA-Z0-9_-]+$/.test(value) + } + return /^\d+$/.test(value) +} + +/** 上游 extractAppleIDFromPath:在 path 段中找 // 或 / */ +export const extractAppleIDFromPath = (path: string, mediaType: string): string => { + const trimmed = String(path ?? '').replace(/^\/+|\/+$/g, '') + if (!trimmed) return '' + const segments = trimmed.split('/') + for (let i = 0; i < segments.length; i++) { + const segment = segments[i] + if (segment !== mediaType && segment !== `${mediaType}s`) continue + if (i + 2 < segments.length) return segments[i + 2] + if (i + 1 < segments.length) return segments[i + 1] + } + return '' +} + +/** 上游 extractAppleID:裸 id / 链接(含地区段与 ?i= 单曲参数)/ 正则兜底 */ +export const extractAppleID = (link: string, mediaType: string): string => { + const raw = String(link ?? '').trim() + if (!raw) return '' + + if (isAppleRawID(raw, mediaType)) return raw + + // 先按 URL 解析(Go url.Parse 的宽松语义:无协议头时把 '?' 前当 path) + let path = '' + let query = '' + try { + const parsed = new URL(raw) + path = parsed.pathname || '' + query = parsed.search || '' + } catch { + const idx = raw.indexOf('?') + path = idx >= 0 ? raw.slice(0, idx) : raw + query = idx >= 0 ? raw.slice(idx) : '' + } + if (path) { + if (mediaType === 'song') { + const songID = new URLSearchParams(query).get('i')?.trim() + if (songID) return songID + } + const fromPath = extractAppleIDFromPath(path, mediaType) + if (fromPath) return fromPath + } + + const patterns: RegExp[] = [ + new RegExp(`${mediaType}/[^/]+/(\\d+)`), + new RegExp(`${mediaType}s?/[^/]+/[^/]+/(\\d+)`), + /\/(\d+)$/, + /[?&]i=(\d+)/, + ] + if (mediaType === 'playlist') { + patterns.push( + /playlists?\/[^/?#]+\/([a-zA-Z0-9._-]+)/, + /playlists?\/([a-zA-Z0-9._-]+)(?:[?#]|$)/, + ) + } + for (const pattern of patterns) { + const match = pattern.exec(raw) + if (match && match[1] != null) return match[1] + } + return '' +} + +// ---------------- 共享请求 ---------------- + +/** 歌曲详情(上游 Parse/GetLyrics/GetDownloadURL 共用的 /v1/catalog//songs/) */ +export const fetchAppleSongResource = async(songId: string, include = ''): Promise => { + const cfg = getAppleConfig() + const params: Record = { extend: 'extendedAssetUrls' } + if (include) params.include = include + const body: any = await appleAmpGet(`/v1/catalog/${cfg.storefront}/songs/${songId}`, params) + const data = body?.data ?? [] + return data.length ? data[0] : null +} + +export default { + parseCookieParts, + getAppleConfig, + fetchAppleToken, + getAppleToken, + invalidateAppleToken, + appleAmpGet, + appleAmpGetTracked, + appleSongFromCatalog, + appleAlbumToPlaylist, + applePlaylistToPlaylist, + extractAppleID, + isAppleRawID, + extractAppleIDFromPath, +} diff --git a/sdk/bili/index.ts b/sdk/bili/index.ts new file mode 100644 index 0000000..db5e2bd --- /dev/null +++ b/sdk/bili/index.ts @@ -0,0 +1,39 @@ +// Bilibili(B站)平台模块 —— 上游 music-lib/bilibili 的移植(平台 id: bili) +// 能力:搜索 / 取流(DASH,VIP 可出 FLAC/Hi-Res)/ 歌单(合集 ugc_season + 多分P稿件)/ +// 链接解析(BV/av、分P)/ 封面 / 扫码登录(sdk/bili/login.ts,凭据落 sdk/auth/store.ts) +// 不实现:专辑、歌词(上游即空实现)、榜单、热搜、歌手、评论、个人歌单、歌单分类(上游均未实现)。 +// 取流入口在 sdk/apis/bili.ts(getMusicUrl),songmid 约定 `|`。 +import musicSearch from './musicSearch.ts' +import lyric from './lyric.ts' +import pic from './pic.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import biliApi from '../apis/bili.ts' +import { parseSongId } from './util.ts' + +const bili = { + musicSearch, + getMusicUrl(songInfo, type) { + return biliApi.getMusicUrl(songInfo, type) + }, + getLyric(songInfo, ...rest) { + return lyric.getLyric(songInfo, ...rest) + }, + getPic(songInfo) { + return pic.getPic(songInfo) + }, + playlist, + parse, + getMusicDetailPageUrl(songInfo) { + if (songInfo?.link) return String(songInfo.link) + const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid + if (!bvid) return 'https://www.bilibili.com/' + const page = Number(songInfo?.page) || 0 + return `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}` + }, +} + +// 登录态(sdk/auth/store.ts 的 bili 凭据,扫码登录写入) +export { status } from './login.ts' + +export default bili diff --git a/sdk/bili/login.ts b/sdk/bili/login.ts new file mode 100644 index 0000000..d512be1 --- /dev/null +++ b/sdk/bili/login.ts @@ -0,0 +1,163 @@ +// Bilibili 扫码登录(上游 bilibili/login.go 的 CreateQRLogin/CheckQRLogin + account.go 的 IsVipAccount) +// 接口: +// GET https://passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key} +// GET https://passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code +// 0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码 +// 成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。 +// 凭据落盘到 sdk/auth/store.ts 的 `bili` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致; +// 上游 New(cookie) 的实例化语义由 getCredentials('bili') 承担(见 util.ts getCookie)。 +// 状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。 +import QRCode from 'qrcode' +import { httpFetch } from '../common/request.ts' +import { getCredentials, setCredentials } from '../auth/store.ts' +import { REFERER, UA } from './util.ts' +import { resetVipCache } from '../apis/bili.ts' + +const QR_GENERATE_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/generate' +const QR_POLL_API = 'https://passport.bilibili.com/x/passport-login/web/qrcode/poll' + +/** 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制 */ +const randomChinaIp = (): string => { + const first = [36, 42, 58, 60, 101, 106, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 171, 175, 180, 182, 183, 210, 211, 218, 219, 220, 221, 222, 223][Math.floor(Math.random() * 35)] + return `${first}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}.${1 + Math.floor(Math.random() * 254)}` +} + +const qrHeaders = (): Record => { + const ip = randomChinaIp() + return { + 'User-Agent': UA, + Referer: REFERER, + 'X-Forwarded-For': ip, + 'X-Real-IP': ip, + } +} + +const cookieMapFromHeaders = (headers: any): Record => { + const raw = headers?.['set-cookie'] + const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : [] + const jar: Record = {} + for (const value of values) { + const pair = String(value).split(';')[0].trim() + const idx = pair.indexOf('=') + if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim() + } + return jar +} + +/** crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备) */ +const cookieMapFromCrossDomain = (crossUrl: string): Record => { + const jar: Record = {} + try { + const url = new URL(String(crossUrl || '')) + for (const name of ['DedeUserID', 'DedeUserID__ckMd5', 'SESSDATA', 'bili_jct', 'sid']) { + const value = url.searchParams.get(name) + if (value) jar[name] = value + } + } catch { + // 非 URL 时忽略 + } + return jar +} + +const cookieString = (jar: Record): string => + Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ') + +/** 创建扫码登录会话:返回 { key, qrurl, qrimg, expiresAt }(qrimg 可直接展示) */ +export const createQRLogin = async() => { + const { statusCode, body } = await httpFetch(QR_GENERATE_API, { headers: qrHeaders(), timeout: 15000 }).promise as any + if (statusCode !== 200 || !body || typeof body !== 'object') { + throw new Error(`获取B站登录二维码失败(HTTP ${statusCode})`) + } + if (body.code !== 0 || !String(body.data?.qrcode_key || '').trim()) { + throw new Error(`获取B站登录二维码失败(${body.code}${body.message ? `: ${body.message}` : ''})`) + } + const key = String(body.data.qrcode_key) + const url = String(body.data.url || '') + const qrimg = await QRCode.toDataURL(url, { errorCorrectionLevel: 'M', margin: 2, width: 360 }).catch(() => '') + return { + key, + qrurl: url, + qrimg, + // 上游 QRLoginSession.ExpiresAt:3 分钟有效期 + expiresAt: Math.floor(Date.now() / 1000) + 180, + } +} + +/** 拉取账号资料(nav;失败不阻断登录) */ +const fetchProfile = async(cookie: string) => { + try { + const { body } = await httpFetch('https://api.bilibili.com/x/web-interface/nav', { + headers: { 'User-Agent': UA, Referer: REFERER, Cookie: cookie }, + timeout: 15000, + }).promise as any + const data = body?.data ?? {} + return { + mid: data.mid ?? null, + nickname: data.uname ?? null, + vipType: Number(data.vipType) || 0, + vipStatus: Number(data.vipStatus) || 0, + } + } catch { + return { mid: null, nickname: null, vipType: 0, vipStatus: 0 } + } +} + +/** + * 轮询扫码状态 + * @returns {{ code: 800|801|802|803, message: string, status: string, profile? }} + * 800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘) + */ +export const checkQRLogin = async(key: string) => { + const qrcodeKey = String(key || '').trim() + if (!qrcodeKey) throw new Error('B站扫码 key 为空') + const { statusCode, headers, body } = await httpFetch(`${QR_POLL_API}?qrcode_key=${encodeURIComponent(qrcodeKey)}`, { + headers: { 'User-Agent': UA, Referer: REFERER }, + timeout: 15000, + }).promise as any + if (statusCode !== 200) { + throw Object.assign(new Error(`轮询B站扫码状态失败(HTTP ${statusCode})`), { status: 502 }) + } + const data = body?.data ?? {} + const code = Number(data.code) + + if (code === 0) { + const jar = { ...cookieMapFromCrossDomain(data.url), ...cookieMapFromHeaders(headers) } + const cookie = cookieString(jar) + if (!cookie) throw new Error('B站登录成功但未获取到 Cookie,请重试') + const profile = await fetchProfile(cookie) + setCredentials('bili', { + cookie, + refreshToken: String(data.refresh_token || ''), + mid: profile.mid, + nickname: profile.nickname, + vipType: profile.vipType, + vipStatus: profile.vipStatus, + }) + resetVipCache() + return { code: 803, message: '登录成功', status: 'success', profile } + } + + const statusMap: Record = { + 86038: [800, '二维码已过期', 'expired'], + 86090: [802, '已扫码,待确认', 'scanned'], + 86101: [801, '等待扫码', 'waiting'], + } + const [mappedCode, message, status] = statusMap[code] ?? [800, String(data.message || `未知状态 ${code}`), 'failed'] + return { code: mappedCode, message, status } +} + +/** 当前登录态(凭据存在性 + 落盘时保存的账号资料) */ +export const status = () => { + const cred = getCredentials('bili') as any + return cred?.cookie + ? { + logged: true, + nickname: cred.nickname ?? null, + mid: cred.mid ?? null, + vipType: cred.vipType ?? 0, + updatedAt: cred.updatedAt, + } + : { logged: false } +} + +export default { createQRLogin, checkQRLogin, status } diff --git a/sdk/bili/lyric.ts b/sdk/bili/lyric.ts new file mode 100644 index 0000000..05185ef --- /dev/null +++ b/sdk/bili/lyric.ts @@ -0,0 +1,12 @@ +// Bilibili 歌词(上游 bilibili/lyric.go 即空实现:GetLyrics 对 bilibili 源恒返回空字符串) +// B 站视频没有公开歌词接口(cc_subtitle 需登录且绝大多数视频没有),上游未实现,此处保持一致: +// 返回统一结构 { lyric: '', tlyric: '' },让 /lyric/bili 有响应而不是 404。 +export const getLyric = (_songInfo?: any, ..._rest: any[]) => { + const requestObj = { + promise: Promise.resolve({ lyric: '', tlyric: '' }), + cancelHttp() {}, + } + return requestObj +} + +export default { getLyric } diff --git a/sdk/bili/musicSearch.ts b/sdk/bili/musicSearch.ts new file mode 100644 index 0000000..0213237 --- /dev/null +++ b/sdk/bili/musicSearch.ts @@ -0,0 +1,57 @@ +// Bilibili 搜索(上游 bilibili/song.go 的 `Search`) +// 接口: https://api.bilibili.com/x/web-interface/search/type?search_type=video&keyword=&page=&page_size= +// 上游对每个搜索结果再调 x/web-interface/view 取首个分P的 cid/时长(B 站取流必须 cid),本移植保持一致, +// 只是把逐个串行改成了 4 并发;单条 view 失败按上游语义跳过该条。 +// 实测(2026-10-02):带 Referer/UA + 匿名(或登录)Cookie 可返回 code 0;风控时返回 -412/-352,会抛错由路由兜底。 +import { apiGet, cleanTitle, fetchView, makeSong, mapLimit } from './util.ts' + +const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type' + +export const search = async(keyword: string, page = 1, limit = 20) => { + const p = Math.max(1, Number(page) || 1) + const l = Math.min(Math.max(1, Number(limit) || 20), 50) + const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}` + const { statusCode, body } = await apiGet(url) + if (statusCode !== 200 || !body || typeof body !== 'object') { + throw new Error(`B站搜索失败(HTTP ${statusCode})`) + } + if (body.code !== 0) { + throw new Error(`B站搜索失败(${body.code}${body.message ? `: ${body.message}` : ''})`) + } + + const items: any[] = body.data?.result ?? [] + const songs = await mapLimit(items, 4, async(item: any) => { + if (!item?.bvid) return null + const view = await fetchView(item.bvid) + const pages: any[] = view?.pages ?? [] + if (!pages.length) return null + const first = pages[0] + const rootTitle = cleanTitle(item.title) + let displayTitle = first.part || '' + if (!displayTitle) displayTitle = rootTitle + else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}` + return makeSong({ + bvid: item.bvid, + cid: first.cid, + name: displayTitle, + artist: item.author || view?.owner?.name || '', + album: item.bvid, + duration: first.duration, + cover: item.pic || view?.pic || '', + page: 1, + }) + }) + + const list = songs.filter(Boolean) + const total = Number(body.data?.numResults) || list.length + const numPages = Number(body.data?.numPages) || 0 + return { + list, + allPage: numPages || p + (list.length >= l ? 1 : 0), + total, + limit: l, + source: 'bili' as const, + } +} + +export default { search } diff --git a/sdk/bili/parse.ts b/sdk/bili/parse.ts new file mode 100644 index 0000000..fc8f951 --- /dev/null +++ b/sdk/bili/parse.ts @@ -0,0 +1,72 @@ +// Bilibili 链接解析(上游 bilibili/song.go 的 `Parse`) +// 支持 https://www.bilibili.com/video/BVxxx?p=2 与 av 号(上游只认 BV,任务要求兼容 av); +// 与上游一致:合集(ugc_season)或多分P链接会被判定为歌单并抛错,请改用 playlist.parsePlaylistLink。 +// 上游 Parse 会顺带填入下载地址,这里同样 best-effort 填充 url(失败不影响元数据返回)。 +import { extractAid, extractBvid, extractPage, fetchPageList, fetchView, makeSong, normalizeCover } from './util.ts' +import { getAudioUrl } from '../apis/bili.ts' +import { parsePlaylistLink } from './playlist.ts' + +export const parseSong = async(link: string) => { + const raw = String(link || '').trim() + const bvid = extractBvid(raw) + const aid = extractAid(raw) + if (!bvid && !aid) throw new Error('无效的B站链接:未找到 BV/av 号') + + const view = await fetchView(bvid || aid) + const pages: any[] = view?.pages ?? [] + + // 上游:合集或多分P视为歌单链接(Parse 只处理单P) + if (view?.ugc_season || pages.length > 1) { + throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)') + } + if (pages.length <= 1) { + try { + const pageList = await fetchPageList(view.bvid || bvid) + if (pageList.length > 1) throw new Error('该链接是合集/多分P视频,请使用歌单解析(parsePlaylistLink)') + } catch (err: any) { + if (String(err?.message || '').includes('歌单解析')) throw err + // 分P列表接口失败时按上游语义忽略 + } + } + if (!pages.length) throw new Error('未找到视频分P') + + let page = extractPage(raw) + if (page > pages.length) page = 1 + const target = pages[page - 1] + + const rootTitle = view.title || '' + let displayTitle = target?.part || '' + if (pages.length === 1 && !displayTitle) displayTitle = rootTitle + else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}` + + const cid = String(target?.cid ?? '') + const song: any = makeSong({ + bvid: view.bvid || bvid, + cid, + name: displayTitle, + artist: view?.owner?.name || '', + album: view.bvid || bvid, + duration: target?.duration, + cover: normalizeCover(view?.pic || ''), + page, + }) + + // 上游 Parse 会立即填充音频地址(失败忽略) + if (cid) { + try { + const audio = await getAudioUrl(song.bvid, cid) + if (audio?.url) { + song.url = audio.url + song.type = audio.type + } + } catch { + // 忽略:元数据仍可返回 + } + } + return song +} + +/** 歌单/合集链接解析(上游 bilibili/playlist.go 的 `ParsePlaylist`) */ +export const parsePlaylist = async(link: string) => parsePlaylistLink(link) + +export default { parseSong, parsePlaylist } diff --git a/sdk/bili/pic.ts b/sdk/bili/pic.ts new file mode 100644 index 0000000..f02c99c --- /dev/null +++ b/sdk/bili/pic.ts @@ -0,0 +1,17 @@ +// Bilibili 封面:搜索结果/合集条目已带封面;仅有 id 时回源 x/web-interface/view 取 pic +import { fetchView, normalizeCover, parseSongId } from './util.ts' + +export const getPic = async(songInfo: any): Promise => { + const direct = String(songInfo?.img || '') + if (/^https?:/i.test(direct)) return direct + const bvid = String(songInfo?.bvid || '') || parseSongId(songInfo?.songmid).bvid + if (!bvid) return '' + try { + const view = await fetchView(bvid) + return normalizeCover(view?.pic || '') + } catch { + return '' + } +} + +export default { getPic } diff --git a/sdk/bili/playlist.ts b/sdk/bili/playlist.ts new file mode 100644 index 0000000..597ea0a --- /dev/null +++ b/sdk/bili/playlist.ts @@ -0,0 +1,257 @@ +// Bilibili 歌单(上游 bilibili/playlist.go;B站的"歌单"= 合集 ugc_season / 多分P稿件) +// SearchPlaylist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回; +// GetPlaylistSongs / ParsePlaylist: id 约定 `season:::` 或 `bvid:`; +// GetPlaylistCategories / GetCategoryPlaylists: 上游显式返回 ErrPlaylistCategoriesUnsupported,本移植保持不支持(抛错)。 +// 注意:上游用于补全合集信息的 x/space/ugc/season 接口现已 404,bvid 路径(view 内自带 sections)是主通道。 +import { apiGet, buildSongsFromPages, buildSongsFromSeasonSections, cleanTitle, extractAid, extractBvid, fetchPageList, fetchSeasonArchiveIndex, fetchSeasonSongs, fetchView, mapLimit, normalizeCover } from './util.ts' + +const SEARCH_URL = 'https://api.bilibili.com/x/web-interface/search/type' + +const countSeasonEpisodes = (sections: any[]): number => { + let count = 0 + for (const sec of sections || []) count += (sec?.episodes || []).length + return count +} + +const seasonKey = (seasonId: any, mid: any, bvid: string) => `season:${seasonId}:${mid}:${bvid}` +const bvidKey = (bvid: string) => `bvid:${bvid}` + +/** 搜索合集/多分P(上游 SearchPlaylist;page/limit 为移植新增的翻页能力,语义同其它平台) */ +export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => { + const p = Math.max(1, Number(page) || 1) + const l = Math.min(Math.max(1, Number(limit) || 20), 50) + const url = `${SEARCH_URL}?search_type=video&keyword=${encodeURIComponent(keyword)}&page=${p}&page_size=${l}` + const { statusCode, body } = await apiGet(url) + if (statusCode !== 200 || !body || typeof body !== 'object') { + throw new Error(`B站歌单搜索失败(HTTP ${statusCode})`) + } + if (body.code !== 0) { + throw new Error(`B站歌单搜索失败(${body.code}${body.message ? `: ${body.message}` : ''})`) + } + + const items: any[] = body.data?.result ?? [] + const seen = new Set() + const result = await mapLimit(items, 4, async(item: any) => { + if (!item?.bvid) return null + const view = await fetchView(item.bvid) + const cover = normalizeCover(item.pic || view?.pic || '') + const season = view?.ugc_season + if (season) { + const id = seasonKey(season.id, view?.owner?.mid, item.bvid) + if (seen.has(id)) return null + seen.add(id) + let trackCount = countSeasonEpisodes(season.sections) + if (!trackCount && season.id && view?.owner?.mid) { + try { + trackCount = (await fetchSeasonSongs(view.owner.mid, season.id)).length + } catch { + // 上游 fetchSeasonSongs 失败同样保持 trackCount 0 + } + } + return { + id, + name: season.title || cleanTitle(item.title), + cover: normalizeCover(season.cover) || cover, + trackCount, + playCount: Number(season?.stat?.view) || undefined, + creator: view?.owner?.name || item.author || '', + description: season.intro || '', + link: `https://www.bilibili.com/video/${item.bvid}`, + source: 'bili' as const, + } + } + const pages: any[] = view?.pages ?? [] + if (pages.length > 1) { + const id = bvidKey(item.bvid) + if (seen.has(id)) return null + seen.add(id) + return { + id, + name: cleanTitle(item.title), + cover, + trackCount: pages.length, + creator: view?.owner?.name || item.author || '', + description: '', + link: `https://www.bilibili.com/video/${item.bvid}`, + source: 'bili' as const, + } + } + return null + }) + + const list = result.filter(Boolean) + return { list, page: p, limit: l, total: list.length, source: 'bili' as const } +} + +/** 由 bvid 构造歌单详情(上游 GetPlaylistSongs 的 bvid 分支 + ParsePlaylist 的元数据) */ +const detailFromBvid = async(bvid: string) => { + const view = await fetchView(bvid) + const realBvid = view?.bvid || bvid + let pages: any[] = view?.pages ?? [] + if (pages.length <= 1) { + const pageList = await fetchPageList(realBvid).catch(() => null) + if (pageList?.length) pages = pageList + } + if (!pages.length) throw new Error('未找到视频分P') + const songs = buildSongsFromPages(realBvid, view?.title || '', view?.owner?.name || '', view?.pic || '', pages) + return { + info: { + name: view?.title || realBvid, + img: normalizeCover(view?.pic || ''), + desc: String(view?.desc || ''), + author: view?.owner?.name || '', + }, + songs, + } +} + +/** 由 `season:::` 构造歌单详情(上游 GetPlaylistSongs 的 season 分支) */ +const detailFromSeason = async(seasonId: string, mid: string, bvid: string) => { + let info = { name: '', img: '', desc: '', author: '' } + let songs: any[] = [] + + if (bvid) { + const view = await fetchView(bvid) + const season = view?.ugc_season + if (season) { + let archiveIndex: Record = {} + let seasonTitle = season.title || '' + let seasonCover = season.cover || '' + // 上游会先拉 archive 索引补全缺失字段;该接口现已 404,失败时按空索引继续 + const idx = await fetchSeasonArchiveIndex(mid, seasonId).catch(() => null) + if (idx) { + archiveIndex = idx.index + seasonTitle = seasonTitle || idx.title + seasonCover = seasonCover || idx.cover + } + songs = buildSongsFromSeasonSections(season.sections || [], seasonTitle, seasonCover, view?.owner?.name || '', archiveIndex) + info = { + name: seasonTitle || view?.title || '', + img: normalizeCover(seasonCover || view?.pic || ''), + desc: season.intro || '', + author: view?.owner?.name || '', + } + } + } + + if (!songs.length) { + songs = await fetchSeasonSongs(mid, seasonId) + if (!info.name) { + const idx = await fetchSeasonArchiveIndex(mid, seasonId).catch(() => null) + if (idx) info = { ...info, name: idx.title || '', img: normalizeCover(idx.cover || ''), desc: '', author: '' } + } + } + if (!songs.length) throw new Error('合集为空或合集接口不可用') + return { info, songs } +} + +/** 解析 id(兼容链接/裸 BV/av/bvid: 前缀) */ +const resolveId = async(id: string): Promise<{ kind: 'link' | 'season' | 'bvid', value: string, parts?: string[] }> => { + const raw = String(id || '').trim() + if (/^https?:/i.test(raw)) return { kind: 'link', value: raw } + if (/^season:/i.test(raw)) return { kind: 'season', value: raw, parts: raw.split(':') } + // 注意先去 `bvid:` 前缀再提 BV:extractBvid 的大小写不敏感匹配会把前缀 "bvid" 本身当成 BV 号 + const bvid = extractBvid(/^bvid:/i.test(raw) ? raw.replace(/^bvid:/i, '') : raw) + if (bvid) return { kind: 'bvid', value: bvid } + const aid = extractAid(raw) + if (aid) { + const view = await fetchView(aid) + const realBvid = view?.bvid || '' + if (!realBvid) throw new Error('未找到视频 BV 号') + return { kind: 'bvid', value: realBvid } + } + throw new Error('无效的歌单 id(支持 season:::、bvid: 或 BV 号/链接)') +} + +const pageSlice = (detail: { info: any, songs: any[] }, page: any, limit: any) => { + const p = Math.max(1, Number(page) || 1) + const l = Math.min(Math.max(1, Number(limit) || 100), 10000) + return { + source: 'bili' as const, + info: detail.info, + list: detail.songs.slice((p - 1) * l, p * l), + page: p, + limit: l, + total: detail.songs.length, + } +} + +/** 歌单详情(契约键 playlist.getPlaylistDetail) */ +export const getPlaylistDetail = async(id: string, page = 1, limit = 100) => { + const target = await resolveId(id) + if (target.kind === 'link') return parsePlaylistLink(target.value) + if (target.kind === 'season') { + const parts = target.parts || [] + if (parts.length < 3) throw new Error('无效的合集 id') + return pageSlice(await detailFromSeason(parts[1], parts[2], parts[3] || ''), page, limit) + } + return pageSlice(await detailFromBvid(target.value), page, limit) +} + +/** 上游 GetPlaylistSongs 的等价导出(只返回歌曲数组) */ +export const getPlaylistSongs = async(id: string): Promise => { + const detail = await getPlaylistDetail(id, 1, 10000) + return detail.list +} + +/** 解析合集/多分P链接(上游 ParsePlaylist;返回详情对象,list 为全量) */ +export const parsePlaylistLink = async(link: string) => { + const raw = String(link || '').trim() + const bvid = extractBvid(/^bvid:/i.test(raw) ? raw.replace(/^bvid:/i, '') : raw) + if (!bvid) { + const aid = extractAid(raw) + if (!aid) throw new Error('无效的B站歌单链接:未找到 BV/av 号') + const view = await fetchView(aid) + return parsePlaylistView(view?.bvid || '', view) + } + const view = await fetchView(bvid) + return parsePlaylistView(bvid, view) +} + +const parsePlaylistView = async(bvid: string, view: any) => { + const season = view?.ugc_season + if (season) { + const id = seasonKey(season.id, view?.owner?.mid, bvid) + const detail = await detailFromSeason(String(season.id), String(view?.owner?.mid ?? ''), bvid) + let trackCount = countSeasonEpisodes(season.sections) + if (!trackCount) trackCount = detail.songs.length + return { + source: 'bili' as const, + id, + info: { ...detail.info, trackCount, playCount: Number(season?.stat?.view) || undefined }, + list: detail.songs, + page: 1, + limit: Math.max(detail.songs.length, 1), + total: detail.songs.length, + } + } + const detail = await detailFromBvid(bvid) + return { + source: 'bili' as const, + id: bvidKey(bvid), + info: { ...detail.info, trackCount: detail.songs.length }, + list: detail.songs, + page: 1, + limit: Math.max(detail.songs.length, 1), + total: detail.songs.length, + } +} + +/** 上游 GetPlaylistCategories 返回 ErrPlaylistCategoriesUnsupported */ +export const getCategories = async(): Promise => { + throw new Error('B站不支持歌单分类(上游未实现)') +} + +/** 上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported */ +export const getCategoryPlaylists = async(_id: string, _page = 1, _limit = 20): Promise => { + throw new Error('B站不支持歌单分类(上游未实现)') +} + +export default { + searchPlaylist, + getPlaylistDetail, + getPlaylistSongs, + parsePlaylistLink, + getCategories, + getCategoryPlaylists, +} diff --git a/sdk/bili/util.ts b/sdk/bili/util.ts new file mode 100644 index 0000000..4bf648d --- /dev/null +++ b/sdk/bili/util.ts @@ -0,0 +1,307 @@ +// Bilibili 公共请求助手(上游 music-lib/bilibili 的 bilibili.go/song.go/playlist.go 共享逻辑) +// 与上游的差异(2026-10-02 实测): +// 1) 上游硬编码的默认 cookie(buvid3=2E109C72-...; SESSDATA=your_sessdata;)当前会被 B 站风控: +// 带上它请求 x/player/playurl 直接返回 HTTP 412(HTML 拦截页),不带 Cookie 反而正常。 +// 因此无登录凭据时不照抄那个默认串,而是先请求一次 www.bilibili.com 首页取新鲜的 +// buvid3/b_nut(进程内缓存);首页获取失败则不带 Cookie(实测匿名搜索/取流均可用)。 +// 2) httpFetch 不跟随 302,取流的手动跟跳在 sdk/apis/bili.ts 内处理。 +// 3) 上游 New(cookie) 的登录凭据来自 sdk/auth/store.ts 的 bili 记录(扫码登录写入)。 + +import { httpFetch } from '../common/request.ts' +import { getCredentials } from '../auth/store.ts' +import { formatPlayTime } from '../common/utils.ts' + +export const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0' +export const REFERER = 'https://www.bilibili.com/' + +const HOME_URL = 'https://www.bilibili.com/' + +/** 上游 UserAgent/Referer 组合(所有接口统一携带) */ +export const biliHeaders = (cookie = ''): Record => ({ + 'User-Agent': UA, + Referer: REFERER, + Accept: 'application/json, text/plain, */*', + ...(cookie ? { Cookie: cookie } : {}), +}) + +/** 已保存的登录 Cookie(未登录返回空串) */ +export const getLoginCookie = (): string => String((getCredentials('bili') as any)?.cookie || '') + +const cookieMapFromHeaders = (headers: any): Record => { + const raw = headers?.['set-cookie'] + const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : [] + const jar: Record = {} + for (const value of values) { + const pair = String(value).split(';')[0].trim() + const idx = pair.indexOf('=') + if (idx > 0) jar[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim() + } + return jar +} + +let anonCookie = '' +let anonCookiePromise: Promise | null = null + +/** 请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘) */ +export const getCookie = async(): Promise => { + const saved = getLoginCookie() + if (saved) return saved + if (anonCookie) return anonCookie + if (!anonCookiePromise) { + anonCookiePromise = (async() => { + try { + const { headers } = await httpFetch(HOME_URL, { headers: biliHeaders(), timeout: 15000 }).promise as any + const jar = cookieMapFromHeaders(headers) + anonCookie = Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ') + } catch { + // 拿不到就用无 Cookie 请求(实测同样可用) + } + return anonCookie + })() + } + return anonCookiePromise +} + +/** 带 B 站请求头的 GET(cookie 省略时自动取登录态/匿名 buvid3;显式传 '' 表示匿名) */ +export const apiGet = async(url: string, cookie?: string): Promise<{ statusCode: number, headers: any, body: any }> => { + const ck = cookie === undefined ? await getCookie() : cookie + return httpFetch(url, { headers: biliHeaders(ck), timeout: 15000 }).promise as any +} + +/** 封面协议补全(上游 normalizeCover) */ +export const normalizeCover = (cover: string): string => { + if (!cover) return '' + return cover.startsWith('//') ? `https:${cover}` : cover +} + +/** 搜索标题去高亮标签(上游 cleanTitle) */ +export const cleanTitle = (title: string): string => { + if (!title) return '' + return String(title).replace(//g, '').replace(/<\/em>/g, '') +} + +/** songmid 约定:`|`(与上游 ID 格式一致) */ +export const parseSongId = (songmid: any): { bvid: string, cid: string } => { + const raw = String(songmid ?? '') + const idx = raw.indexOf('|') + if (idx < 0) return { bvid: raw, cid: '' } + return { bvid: raw.slice(0, idx), cid: raw.slice(idx + 1) } +} + +/** 从链接/id 提取 BV 号(上游正则 `(BV\w+)` 的等价实现,兼容小写 bv) */ +export const extractBvid = (link: string): string => String(link || '').match(/BV[0-9A-Za-z]+/i)?.[0] || '' + +/** 从链接提取分P(上游 `[?&]p=(\d+)`) */ +export const extractPage = (link: string): number => { + const m = String(link || '').match(/[?&]p=(\d+)/) + const p = m ? parseInt(m[1], 10) : 0 + return p > 0 ? p : 1 +} + +/** 从链接提取 av 号(上游未支持,任务要求兼容) */ +export const extractAid = (link: string): string => { + const m = String(link || '').match(/\bav(\d+)\b/i) + return m ? m[1] : '' +} + +/** 并发映射(限制并发数,单条失败返回 null) */ +export const mapLimit = async(items: any[], limit: number, fn: (item: any, index: number) => Promise): Promise => { + const list = Array.isArray(items) ? items : [] + const out: any[] = new Array(list.length).fill(null) + let next = 0 + const size = Math.max(1, Math.min(Number(limit) || 1, list.length || 1)) + await Promise.all(Array.from({ length: size }, async() => { + while (next < list.length) { + const i = next++ + try { + out[i] = await fn(list[i], i) + } catch { + out[i] = null + } + } + })) + return out +} + +/** 统一歌曲形状(对齐 MusicServer 契约;albumId/albumName 沿用上游的 bvid) */ +export const makeSong = ({ bvid, cid, name, artist, album, duration, cover, page = 1 }: { + bvid: string + cid: string | number + name?: string + artist?: string + album?: string + duration?: number | string + cover?: string + page?: number +}) => { + const seconds = Number(duration) || 0 + const url = `https://www.bilibili.com/video/${bvid}${page > 1 ? `?p=${page}` : ''}` + return { + songmid: `${bvid}|${cid}`, + name: name || '', + singer: artist || '', + albumId: bvid || '', + albumName: album || bvid || '', + interval: seconds ? formatPlayTime(seconds) : 0, + img: normalizeCover(cover || '') || null, + lrc: null, + types: [] as any[], + _types: {} as Record, + source: 'bili' as const, + bvid, + cid: String(cid ?? ''), + page, + link: url, + } +} + +// ---- 视频/分P/合集接口(上游 bilibili.go 各 fetch* 的对应实现) ---- + +/** 视频详情(x/web-interface/view);bvid 或 aid 均可,返回 data 节点 */ +export const fetchView = async(id: string): Promise => { + const raw = String(id || '').trim() + if (!raw) throw new Error('B站视频 id 为空') + const query = /^BV/i.test(raw) ? `bvid=${encodeURIComponent(raw)}` : `aid=${encodeURIComponent(raw.replace(/^av/i, ''))}` + const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/web-interface/view?${query}`) + if (statusCode !== 200 || !body || body.code !== 0 || !body.data) { + throw new Error(`B站视频信息获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`) + } + return body.data +} + +/** 分P列表(x/player/pagelist),返回 data 数组 */ +export const fetchPageList = async(bvid: string): Promise => { + const { statusCode, body } = await apiGet(`https://api.bilibili.com/x/player/pagelist?bvid=${encodeURIComponent(bvid)}`) + if (statusCode !== 200 || !body || body.code !== 0) { + throw new Error(`B站分P列表获取失败(${body?.code ?? `HTTP ${statusCode}`}${body?.message ? `: ${body.message}` : ''})`) + } + return body.data ?? [] +} + +/** 合集归档索引(x/space/ugc/season;上游 fetchSeasonArchiveIndex;该接口现已 404,调用方需容错) */ +export const fetchSeasonArchiveIndex = async(mid: string | number, seasonId: string | number): Promise<{ index: Record, title: string, cover: string }> => { + if (!mid || !seasonId) throw new Error('合集信息无效') + const index: Record = {} + let seasonTitle = '' + let seasonCover = '' + let processed = 0 + let pageNum = 1 + const pageSize = 30 + for (;;) { + const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`) + if (!body || body.code !== 0) { + throw new Error(`B站合集信息获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`) + } + if (!seasonTitle) seasonTitle = body.data?.season?.title || '' + if (!seasonCover) seasonCover = body.data?.season?.cover || '' + const archives = body.data?.archives ?? [] + if (!archives.length) break + for (const arc of archives) { + const meta = { title: arc?.title || '', cover: normalizeCover(arc?.cover || ''), duration: Number(arc?.duration) || 0 } + if (arc?.bvid) index[`bvid:${arc.bvid}`] = meta + if (arc?.cid) index[`cid:${arc.cid}`] = meta + } + processed += archives.length + const total = Number(body.data?.page?.total) || 0 + if (!total || processed >= total) break + pageNum += 1 + if (pageNum > 100) break + } + return { index, title: seasonTitle, cover: seasonCover } +} + +/** 由分P数组构造歌曲(上游 buildSongsFromPages) */ +export const buildSongsFromPages = (bvid: string, rootTitle: string, author: string, cover: string, pages: any[]): any[] => { + return (pages || []).map((page: any, i: number) => { + const part = page?.part || '' + let displayTitle = part + if (pages.length === 1 && !displayTitle) displayTitle = rootTitle + else if (displayTitle !== rootTitle) displayTitle = `${rootTitle} - ${displayTitle}` + return makeSong({ + bvid, + cid: page?.cid, + name: displayTitle, + artist: author, + album: bvid, + duration: page?.duration, + cover, + page: i + 1, + }) + }) +} + +/** 由合集 sections 构造歌曲(上游 buildSongsFromSeasonSections) */ +export const buildSongsFromSeasonSections = ( + sections: any[], + seasonTitle: string, + seasonCover: string, + artistName: string, + archiveIndex: Record = {}, +): any[] => { + const songs: any[] = [] + const artist = artistName || seasonTitle + for (const sec of sections || []) { + for (const ep of sec?.episodes || []) { + let cover = ep?.cover || '' + const meta = archiveIndex[`bvid:${ep?.bvid}`] ?? (ep?.cid ? archiveIndex[`cid:${ep.cid}`] : undefined) + if (!cover) cover = ep?.arc?.pic || '' + if (!cover) cover = seasonCover || meta?.cover || '' + let duration = Number(ep?.duration) || 0 + if (!duration) duration = Number(ep?.page?.duration) || 0 + if (!duration) duration = Number(ep?.arc?.duration) || 0 + if (!duration) duration = meta?.duration || 0 + let name = ep?.title || ep?.arc?.title || ep?.page?.part || meta?.title || '' + songs.push(makeSong({ + bvid: ep?.bvid || '', + cid: ep?.cid, + name, + artist, + album: ep?.bvid || '', + duration, + cover, + page: 1, + })) + } + } + return songs +} + +/** 合集歌曲(x/space/ugc/season,上游 fetchSeasonSongs;该接口现已 404,调用方需容错) */ +export const fetchSeasonSongs = async(mid: string | number, seasonId: string | number): Promise => { + if (!mid || !seasonId) throw new Error('合集信息无效') + const all: any[] = [] + let processed = 0 + let seasonTitle = '' + let seasonCover = '' + let pageNum = 1 + const pageSize = 30 + for (;;) { + const { body } = await apiGet(`https://api.bilibili.com/x/space/ugc/season?mid=${mid}&season_id=${seasonId}&page_num=${pageNum}&page_size=${pageSize}`) + if (!body || body.code !== 0) { + throw new Error(`B站合集列表获取失败(${body?.code ?? '非 JSON 响应'}${body?.message ? `: ${body.message}` : ''})`) + } + if (!seasonTitle) seasonTitle = body.data?.season?.title || '' + if (!seasonCover) seasonCover = body.data?.season?.cover || '' + const archives = body.data?.archives ?? [] + if (!archives.length) break + for (const arc of archives) { + if (!arc?.cid) continue + all.push(makeSong({ + bvid: arc?.bvid || '', + cid: arc.cid, + name: arc?.title || '', + artist: '', + album: seasonTitle || arc?.bvid || '', + duration: arc?.duration, + cover: normalizeCover(arc?.cover || seasonCover), + page: 1, + })) + } + processed += archives.length + const total = Number(body.data?.page?.total) || 0 + if (!total || processed >= total) break + pageNum += 1 + if (pageNum > 100) break + } + return all +} diff --git a/sdk/index.ts b/sdk/index.ts index 22cd66a..4fb382e 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -7,6 +7,12 @@ import xm from './xm/index.ts' import me from './me/index.ts' import qt from './qt/index.ts' import lr from './lr/index.ts' +import migu from './migu/index.ts' +import qian from './qian/index.ts' +import joox from './joox/index.ts' +import bili from './bili/index.ts' +import fivesing from './5sing/index.ts' +import apple from './apple/index.ts' import { supportQualitys as kwQualitys } from './apis/kw.ts' import { supportQualitys as kgQualitys } from './apis/kg.ts' import { supportQualitys as txQualitys } from './apis/tx.ts' @@ -16,6 +22,12 @@ import { supportQualitys as xmQualitys } from './apis/xm.ts' import { supportQualitys as meQualitys } from './apis/me.ts' import { supportQualitys as qtQualitys } from './apis/qt.ts' import { supportQualitys as lrQualitys } from './apis/lr.ts' +import { supportQualitys as miguQualitys } from './apis/migu.ts' +import { supportQualitys as qianQualitys } from './apis/qian.ts' +import { supportQualitys as jooxQualitys } from './apis/joox.ts' +import { supportQualitys as biliQualitys } from './apis/bili.ts' +import { supportQualitys as fivesingQualitys } from './apis/5sing.ts' +import { supportQualitys as appleQualitys } from './apis/apple.ts' import type { PlatformModule, SearchResult, SongInfo } from './types.ts' import { sizeFormate, @@ -41,6 +53,13 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul { id: 'me', name: '猫耳FM', module: me, noAggregate: true }, { id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true }, { id: 'lr', name: '懒人听书', module: lr, noAggregate: true }, + { id: 'migu', name: '咪咕音乐', module: migu }, + { id: 'qian', name: '千千音乐', module: qian }, + { id: 'joox', name: 'JOOX', module: joox }, + { id: 'bili', name: '哔哩哔哩', module: bili }, + { id: '5sing', name: '5sing', module: fivesing }, + // Apple Music 仅提供 preview 试听片段(完整曲为 DRM),不参与跨平台精确匹配 + { id: 'apple', name: 'Apple Music', module: apple, noAggregate: true }, ] const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module])) @@ -62,6 +81,12 @@ export const supportQuality: Record = { me: meQualitys, qt: qtQualitys, lr: lrQualitys, + migu: miguQualitys, + qian: qianQualitys, + joox: jooxQualitys, + bili: biliQualitys, + '5sing': fivesingQualitys, + apple: appleQualitys, } const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string) diff --git a/sdk/joox/album.ts b/sdk/joox/album.ts new file mode 100644 index 0000000..a68de5a --- /dev/null +++ b/sdk/joox/album.ts @@ -0,0 +1,169 @@ +// JOOX 专辑(上游 joox/album.go 移植 + 新版 openjoox2 通道) +// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(section_type=1 专辑区,item.type=2 专辑) +// 曲目:GET https://cache.api.joox.com/openjoox2/v1/album//tracks?country=hk&lang=zh_TW&index=&num=&secret= +// (web 播放器通道,需 md5 签名;实测 2026-10-02 HTTP 200) +// 兜底:www.joox.com//album/ SSR __NEXT_DATA__.props.pageProps.albumData(上游做法,需 X-Forwarded-For) +// 链接解析:ParseAlbum 的正则与裸 ID 规则同上游。 + +import { httpFetch } from '../request.ts' +import { + jooxHeaders, + jooxSign, + jooxAlbumLink, + normalizeJooxID, + normalizeJooxLang, + pickJooxImage, + jooxArtists, + firstNonEmpty, + firstNonZero, + extractJooxNextData, + fetchJooxPage, +} from './util.ts' +import { JOOX_OPENJOOX2 } from './musicInfo.ts' +import { jooxSongFromTrack } from './song.ts' + +const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search' + +/** openjoox2 专辑曲目(含专辑元信息) */ +const fetchAlbumTracks = async(albumId: string, page: number, limit: number) => { + const country = 'hk' + const lang = normalizeJooxLang('zh_hk') + const index = Math.max(0, (page - 1) * limit) + const params = { country, lang, index, num: limit } + const url = `${JOOX_OPENJOOX2}/album/${albumId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}` + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + if (statusCode != 200 || !body || body.code) return null + + const tracks: any[] = body?.tracks?.items ?? [] + const list = tracks.map(item => jooxSongFromTrack(item, body?.name, pickJooxImage(body?.images), albumId)).filter(Boolean) + if (!list.length) return null + + const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, list.length) + return { + source: 'joox' as const, + info: { + name: String(firstNonEmpty(body?.name, albumId)), + img: pickJooxImage(body?.images) || null, + desc: String(firstNonEmpty(body?.description, body?.publish_date)), + author: jooxArtists(body?.artist_list), + }, + list, + page, + limit, + total, + } +} + +/** SSR 页面专辑数据(上游 fetchAlbumPageData + fetchAlbumDetail 的 data 部分) */ +const fetchAlbumPageData = async(albumId: string) => { + const { body: html } = await fetchJooxPage(jooxAlbumLink(albumId)) + const nextData = extractJooxNextData(html) + const pageProps = nextData?.props?.pageProps + const albumData = pageProps?.albumData + if (normalizeJooxID(albumData?.id)) return albumData + const nested = pageProps?.content?.page?.albumData + if (normalizeJooxID(nested?.id)) return nested + throw new Error('joox 专辑页数据不存在') +} + +/** 专辑详情(openjoox2 优先,SSR 页兜底) */ +export const getAlbumDetail = async(rawId: string, page = 1, limit = 50) => { + const albumId = normalizeJooxID(rawId) + if (!albumId) throw new Error('joox 专辑 id 无效') + try { + const detail = await fetchAlbumTracks(albumId, page, limit) + if (detail) return detail + } catch { + // 通道不可用时走页面兜底 + } + return getAlbumDetailFromPage(albumId, page, limit) +} + +/** 专辑详情(SSR 网页兜底通道,上游 fetchAlbumDetail 的页面路径) */ +export const getAlbumDetailFromPage = async(rawId: string, page = 1, limit = 50) => { + const albumId = normalizeJooxID(rawId) + if (!albumId) throw new Error('joox 专辑 id 无效') + const albumData = await fetchAlbumPageData(albumId) + const items: any[] = albumData?.trackList?.items ?? [] + const pageStart = Math.max(0, (page - 1) * limit) + const pageItems = items.slice(pageStart, pageStart + limit) + const cover = String(firstNonEmpty(pickJooxImage(albumData?.images), albumData?.imgSrc)) + const list = pageItems + .map(item => jooxSongFromTrack(item, albumData?.title, cover, albumId)) + .filter(Boolean) + if (!list.length) throw new Error('joox 专辑无歌曲') + + const total = firstNonZero(albumData?.trackList?.total_count, albumData?.trackList?.list_count, items.length) + return { + source: 'joox' as const, + info: { + name: String(firstNonEmpty(albumData?.title, albumId)), + img: cover || null, + desc: String(firstNonEmpty(albumData?.description, albumData?.publishDate)), + author: jooxArtists(albumData?.artistList), + }, + list, + page, + limit, + total, + } +} + +/** 专辑曲目(上游 GetAlbumSongs:仅曲目列表) */ +export const getAlbumSongs = async(id: string, page = 1, limit = 50) => { + const { list } = await getAlbumDetail(id, page, limit) + return list +} + +/** 专辑搜索(上游 SearchAlbum) */ +export const searchAlbum = async(keyword: string, page = 1, limit = 20) => { + const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}` + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + if (statusCode != 200 || !body?.section_list) { + return { list: [], total: 0, page, limit, source: 'joox' as const } + } + + const list: any[] = [] + const seen = new Set() + for (const section of body.section_list) { + if (Number(section?.section_type) !== 1) continue + for (const item of section?.item_list ?? []) { + if (Number(item?.type) !== 2) continue + const album = item?.album ?? {} + const id = normalizeJooxID(album.id) + if (!id || seen.has(id)) continue + seen.add(id) + list.push({ + id, + name: String(album.name ?? ''), + cover: pickJooxImage(album.images) || null, + creator: jooxArtists(album.artist_list), + description: String(album.publish_date ?? '').trim(), + publishDate: String(album.publish_date ?? '').trim(), + trackCount: 0, + link: jooxAlbumLink(id), + source: 'joox' as const, + }) + } + } + + return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const } +} + +/** 专辑链接解析(上游 ParseAlbum) */ +export const parseAlbum = async(link: string) => { + const patterns = [ + /joox\.com\/.*\/album\/([^/?#]+)/, + /h_activity_id=([^&]+)/, + /albumid=([^&]+)/, + ] + for (const pattern of patterns) { + const m = pattern.exec(String(link ?? '')) + if (m) return getAlbumDetail(m[1]) + } + const raw = String(link ?? '').trim() + if (raw.length > 10 && !raw.includes('/')) return getAlbumDetail(raw) + throw new Error('invalid joox album link') +} + +export default { getAlbumDetail, getAlbumSongs, searchAlbum, parseAlbum } diff --git a/sdk/joox/index.ts b/sdk/joox/index.ts new file mode 100644 index 0000000..b5aaf2f --- /dev/null +++ b/sdk/joox/index.ts @@ -0,0 +1,62 @@ +// JOOX(腾讯海外音乐,东南亚)平台模块 +// 匿名可用面(2026-10-02 实测,需 X-Forwarded-For 东南亚 IP 绕过 www 地区重定向): +// 搜索 / 专辑(搜索+详情+链接解析)/ 歌单(搜索+详情+分类+链接解析)/ 歌词 / 链接解析(歌曲)/ +// 取流(游客 128k MP3,VIP 曲目为 preview/refrain 片段)。 +// 通道: +// - 搜索/专辑搜索/歌单搜索:cache.api.joox.com/openjoox/v3/search(无签名) +// - 取流/歌曲详情/专辑曲目/歌单曲目:cache.api.joox.com/openjoox2/v1(需 md5 签名,见 util.jooxSign) +// - 歌词:api.joox.com/web-fcgi-bin/web_lyric(旧接口仍可用;失败回退 openjoox2 lrc_content) +// - 分类/歌单/专辑 SSR 兜底:www.joox.com __NEXT_DATA__ +// 上游 New(cookie):默认游客 Cookie 来自 joox.go 常量;/login/joox 绑定后从 sdk/auth/store.ts 读取, +// 登录 cookie 的 wmid/session_key 会作为 uid/usk 参与 openjoox2 签名(VIP 完整曲目依赖它)。 +// userPlaylist:上游 joox/user_playlist.go 返回 ErrUserPlaylistsUnsupported,未实现(不造)。 +import jooxApi from '../apis/joox.ts' +import musicSearch from './musicSearch.ts' +import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import lyric from './lyric.ts' +import { getTrackDetail } from './musicInfo.ts' +import { pickJooxImage, normalizeJooxID, jooxSongLink } from './util.ts' +import { getCredentials } from '../auth/store.ts' + +const joox = { + musicSearch, + getMusicUrl(songInfo, type) { + return jooxApi.getMusicUrl(songInfo, type) + }, + getLyric(songInfo, ...rest) { + return lyric.getLyric(songInfo, ...rest) + }, + getPic(songInfo) { + const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? '') + return getTrackDetail(songId).then(detail => pickJooxImage(detail?.images) || null) + }, + album, + playlist, + // 契约别名:JOOX 的「分类/列表」能力即 playlist.go 的歌单分类,没有独立的 songList 接口, + // 这里把同一套分类/分类歌单挂到 songList 键,便于 /song-list/joox 路由复用。 + songList: { + sortList: [{ name: '全部', id: '' }], + getTags() { + return playlist.getCategories() + }, + getList(sortId: string, tagId: string, page = 1, limit = 30) { + return playlist.getCategoryPlaylists(tagId ?? sortId ?? '', page, limit) + }, + }, + parse, + getMusicDetailPageUrl(songInfo) { + return jooxSongLink(songInfo?.songmid ?? songInfo?.id ?? '') + }, +} + +/** 登录状态(凭据存在性判断;无在线校验接口) */ +export const status = () => { + const cred = getCredentials('joox') as { cookie?: string, updatedAt?: string } | null + return cred?.cookie + ? { logged: true, updatedAt: cred.updatedAt } + : { logged: false } +} + +export default joox diff --git a/sdk/joox/lyric.ts b/sdk/joox/lyric.ts new file mode 100644 index 0000000..bb85cc5 --- /dev/null +++ b/sdk/joox/lyric.ts @@ -0,0 +1,84 @@ +// JOOX 歌词(上游 joox/lyric.go 移植) +// 端点:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=&country=sg&lang=zh_cn +// 响应形如 MusicJsonCallback({"code":0,"lyric":""}),lyric 为 base64 UTF-8 歌词。 +// 实测(2026-10-02)该旧接口仍可用(web_get_songinfo 已 404,但 web_lyric 未下线)。 +// 兜底:openjoox2 /track 详情的 lrc_content(同为 base64),旧接口失败时使用。 +// 解码:base64 → UTF-8 → HTML 实体(& 等)→ { lyric, tlyric: '' } +// 统一接口约定:{ promise, cancelHttp };promise resolve → { lyric, tlyric } + +import { httpFetch } from '../request.ts' +import { jooxHeaders, decodeJooxLyric, normalizeJooxID, firstNonEmpty } from './util.ts' +import { buildTrackUrl } from './musicInfo.ts' + +export const LEGACY_LYRIC_URL = 'https://api.joox.com/web-fcgi-bin/web_lyric' + +/** 解析 MusicJsonCallback(...) / 裸 JSON 包裹 */ +export const parseLyricPayload = (body: any): any => { + if (body && typeof body === 'object') return body + let text = String(body ?? '') + const idx = text.indexOf('MusicJsonCallback(') + if (idx >= 0) { + text = text.slice(idx + 'MusicJsonCallback('.length) + text = text.replace(/\)\s*;?\s*$/, '') + } + try { + return JSON.parse(text) + } catch { + return null + } +} + +/** 上游 GetLyrics 的纯逻辑部分:web_lyric 响应体 → 歌词文本 */ +export const parseLegacyLyric = (statusCode: number, body: any): string => { + if (statusCode != 200) throw new Error(`joox 歌词获取失败(HTTP ${statusCode})`) + const payload = parseLyricPayload(body) + if (!payload) throw new Error('joox 歌词解析失败') + const raw = String(firstNonEmpty(payload.lyric, '')) + if (Number(payload.code ?? 0) !== 0 || !raw) throw new Error('joox 歌词不存在或为空') + return decodeJooxLyric(raw) +} + +/** 上游 GetLyrics 通道(非取消句柄版,测试/内部用) */ +export const getLegacyLyric = async(songId: string): Promise => { + const url = `${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn` + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + return parseLegacyLyric(statusCode, body) +} + +export const getLyric = (songInfo: any, ..._rest: any[]) => { + const requestObj: { promise: any, cancelHttp: () => void } = { promise: null, cancelHttp() {} } + let current: any = null + requestObj.cancelHttp = () => { + if (current?.cancelHttp) current.cancelHttp() + } + + const songId = normalizeJooxID(songInfo?.songmid ?? songInfo?.id ?? songInfo?.songid ?? '') + + requestObj.promise = (async() => { + if (!songId) throw new Error('joox 歌曲 id 无效') + + // 1) 上游 web_lyric 通道 + let legacyErr: any = null + try { + current = httpFetch( + `${LEGACY_LYRIC_URL}?musicid=${encodeURIComponent(songId)}&country=sg&lang=zh_cn`, + { headers: jooxHeaders(), timeout: 20000 }, + ) + const { statusCode, body } = await current.promise + return { lyric: parseLegacyLyric(statusCode, body), tlyric: '' } + } catch (err) { + legacyErr = err + } + + // 2) 兜底:openjoox2 详情 lrc_content(base64) + current = httpFetch(buildTrackUrl(songId), { headers: jooxHeaders(), timeout: 20000 }) + const { statusCode, body } = await current.promise as any + const raw = String(firstNonEmpty(body?.lrc_content, '')) + if (statusCode != 200 || !raw) throw legacyErr ?? new Error('joox 歌词不存在或为空') + return { lyric: decodeJooxLyric(raw), tlyric: '' } + })() + + return requestObj +} + +export default { getLyric, getLegacyLyric, parseLyricPayload, parseLegacyLyric } diff --git a/sdk/joox/musicInfo.ts b/sdk/joox/musicInfo.ts new file mode 100644 index 0000000..64fd1e3 --- /dev/null +++ b/sdk/joox/musicInfo.ts @@ -0,0 +1,83 @@ +// JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口) +// 端点:GET https://cache.api.joox.com/openjoox2/v1/track/ +// ?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=][&usk=]&secret= +// 签名:secret = md5('Jo0x@t3Nc3nT' + 'country=..&lang=..&lyric=1&fs=..&im=..[&uid=..][&usk=..]&id=..') +// 参数按插入序参与签名;id 用 '/' → '_' 替换后的值,且发布 URL 前会从 query 中删除 id。 +// 该签名算法由官网 player-d JS bundle 逆向 + 实测比对(见 tmp/joox-test.mjs 断言)。 +// 上游 Go 的 api.joox.com/web-fcgi-bin/web_get_songinfo 已于 2026-10 前 404,此文件为其替代。 +// fs=1 请求完整曲目(游客仍可能拿到 preview/refrain),im=mobile 标记。 +// 返回体含 play_url_list / refrain_url / lrc_content(base64) / qrc_content(base64) / +// has_hq / has_hifi / vip_flag / node_is_preview 等。 + +import { httpFetch } from '../request.ts' +import { + jooxHeaders, + jooxSign, + jooxLoginCookie, + cookieValue, + normalizeJooxID, + normalizeJooxLang, +} from './util.ts' + +export const JOOX_OPENJOOX2 = 'https://cache.api.joox.com/openjoox2/v1' + +interface TrackOptions { + country?: string + lang?: string + /** true 请求完整曲目(fs=1),false 预览(fs=0) */ + fullSong?: boolean + isMobile?: boolean +} + +/** 构造 openjoox2 歌曲详情 URL(含签名;登录态自动带 uid/usk) */ +export const buildTrackUrl = (rawId: unknown, options: TrackOptions = {}): string => { + const id = String(normalizeJooxID(rawId)).replace(/\//g, '_') + const country = options.country || 'hk' + const lang = normalizeJooxLang(options.lang || 'zh_TW') + const params: Record = { + country, + lang, + lyric: 1, + fs: options.fullSong ? 1 : 0, + im: options.isMobile ? 1 : 0, + } + // 仅绑定登录凭据后才带 uid/usk(游客态与官网播放器一致,不带这两项) + const loginCookie = jooxLoginCookie() + const uid = loginCookie ? cookieValue(loginCookie, 'wmid') : '' + const usk = loginCookie ? cookieValue(loginCookie, 'session_key') : '' + if (uid) params.uid = uid + if (usk) params.usk = usk + // 签名时包含 id(与 web 播放器一致),发布 query 时再删除 + const secret = jooxSign({ ...params, id }) + const query = Object.entries({ ...params, secret }) + .map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`) + .join('&') + return `${JOOX_OPENJOOX2}/track/${id}?${query}` +} + +/** 歌曲详情(openjoox2;上游 fetchSongInfo 的替代实现) */ +export const getTrackDetail = async (rawId: unknown, options: TrackOptions = {}): Promise => { + const id = normalizeJooxID(rawId) + if (!id) throw new Error('joox 歌曲 id 无效') + const url = buildTrackUrl(id.replace(/\//g, '_'), options) + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + if (statusCode != 200 || !body || typeof body !== 'object' || body.code) { + const msg = body?.msg || body?.message || `HTTP ${statusCode}` + throw new Error(`joox 歌曲详情获取失败:${msg}`) + } + return body +} + +/** 从详情里挑选可播放地址:play_url_list 优先,refrain_url 兜底;并还原 & 实体 */ +export const pickTrackUrl = (detail: any): string => { + const clean = (value: any) => String(value ?? '').replace(/&/g, '&') + const urls: any[] = Array.isArray(detail?.play_url_list) ? detail.play_url_list : [] + for (const url of urls) { + const text = clean(url) + if (text.startsWith('http')) return text + } + const refrain = clean(detail?.refrain_url) + return refrain.startsWith('http') ? refrain : '' +} + +export default { buildTrackUrl, getTrackDetail, pickTrackUrl } diff --git a/sdk/joox/musicSearch.ts b/sdk/joox/musicSearch.ts new file mode 100644 index 0000000..fe3e201 --- /dev/null +++ b/sdk/joox/musicSearch.ts @@ -0,0 +1,42 @@ +// JOOX 搜索(上游 joox/song.go Search 移植) +// 端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword= +// 返回 section_list(0 歌曲 / 2 歌手 / 1 专辑 / 3 歌单 / 7 用户),歌曲在 item_list[].song[].song_info。 +// 该接口无分页参数(上游也未分页),page 参数仅为统一契约保留;allPage 恒为 1。 +// 实测(2026-10-02):英文/中文关键词均 HTTP 200。 + +import { httpFetch } from '../request.ts' +import { jooxHeaders } from './util.ts' +import { jooxSongFromSearch } from './song.ts' + +const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search' + +export const search = async(keyword: string, page = 1, limit = 20) => { + const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}` + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + if (statusCode != 200 || !body?.section_list) { + return { list: [], allPage: 0, total: 0, limit, source: 'joox' as const } + } + + const list: any[] = [] + const seen = new Set() + for (const section of body.section_list) { + for (const item of section?.item_list ?? []) { + for (const entry of item?.song ?? []) { + const song = jooxSongFromSearch(entry?.song_info) + if (!song || seen.has(song.songmid)) continue + seen.add(song.songmid) + list.push(song) + } + } + } + + return { + list: list.slice(0, limit), + allPage: 1, + total: list.length, + limit, + source: 'joox' as const, + } +} + +export default { search } diff --git a/sdk/joox/parse.ts b/sdk/joox/parse.ts new file mode 100644 index 0000000..b5c925f --- /dev/null +++ b/sdk/joox/parse.ts @@ -0,0 +1,28 @@ +// JOOX 链接解析(上游 joox/song.go Parse + album.go ParseAlbum + playlist.go ParsePlaylist) +// 歌曲:joox.com//single/ 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链) +// 专辑:joox.com/.../album/、h_activity_id=、albumid= → album.getAlbumDetail +// 歌单:joox.com/.../playlist/、playlistid|playlist_id|id= → playlist.getPlaylistDetail + +import { normalizeJooxID } from './util.ts' +import { getTrackDetail, pickTrackUrl } from './musicInfo.ts' +import { jooxSongFromDetail } from './song.ts' +import { getAlbumDetail, parseAlbum } from './album.ts' +import { getPlaylistDetail, parsePlaylist } from './playlist.ts' + +/** 歌曲链接 → Song(上游 Parse 的正则与裸 ID 规则) */ +export const parseSong = async(link: string) => { + const raw = String(link ?? '').trim() + const m = /joox\.com\/.*\/single\/([^/?#]+)/.exec(raw) + let songId = '' + if (m) { + songId = normalizeJooxID(m[1]) + } else if (raw.length > 10 && !raw.includes('/')) { + songId = normalizeJooxID(raw) + } else { + throw new Error('invalid joox link') + } + const detail = await getTrackDetail(songId) + return jooxSongFromDetail(detail, pickTrackUrl(detail)) +} + +export default { parseSong, parseAlbum, parsePlaylist } diff --git a/sdk/joox/playlist.ts b/sdk/joox/playlist.ts new file mode 100644 index 0000000..ec7cbd3 --- /dev/null +++ b/sdk/joox/playlist.ts @@ -0,0 +1,316 @@ +// JOOX 歌单(上游 joox/playlist.go 移植:OpenJOOX 接口 + 网页数据兜底两条路) +// 搜索:GET https://cache.api.joox.com/openjoox/v3/search(item.type=1 editor_playlist) +// 曲目:GET https://cache.api.joox.com/openjoox2/v1/playlist//tracks?...&secret= ← 新版 web 播放器通道 +// (上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留) +// 兜底:www.joox.com/{id,sg,hk}/playlist/ SSR __NEXT_DATA__ +// props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体) +// 分类:www.joox.com/sg/playlist(302 → / 首页)__NEXT_DATA__.props.pageProps.mlList +// 链接解析:joox.com/{region}/playlist/、playlistid/id 查询参数、裸 ID + +import { httpFetch } from '../request.ts' +import { + jooxHeaders, + jooxSign, + jooxPlaylistLink, + jooxPlaylistLinks, + normalizeJooxID, + normalizeJooxLang, + pickJooxImage, + firstNonEmpty, + firstNonZero, + decodeJooxBase64Text, + extractJooxNextData, + fetchJooxPage, +} from './util.ts' +import { JOOX_OPENJOOX2 } from './musicInfo.ts' +import { jooxSongFromTrack } from './song.ts' + +const SEARCH_URL = 'https://cache.api.joox.com/openjoox/v3/search' +const CATEGORY_PAGE = 'https://www.joox.com/sg/playlist' + +const mapPlaylistSummary = (item: any) => { + const id = normalizeJooxID(item?.id) + if (!id) return null + return { + id, + name: String(item?.name ?? ''), + cover: pickJooxImage(item?.images) || null, + trackCount: 0, + creator: '', + description: '', + link: jooxPlaylistLink(id), + source: 'joox' as const, + } +} + +/** openjoox2 歌单曲目(含歌单元信息) */ +const fetchPlaylistTracks = async(playlistId: string, page: number, limit: number) => { + const country = 'hk' + const lang = normalizeJooxLang('zh_hk') + const index = Math.max(0, (page - 1) * limit) + const params = { country, lang, index, num: limit } + const url = `${JOOX_OPENJOOX2}/playlist/${playlistId}/tracks?country=${country}&lang=${lang}&index=${index}&num=${limit}&secret=${jooxSign(params)}` + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + if (statusCode != 200 || !body || body.code) return null + + const list = (body?.tracks?.items ?? []) + .map(item => jooxSongFromTrack(item, String(body?.name ?? ''), pickJooxImage(body?.images))) + .filter(Boolean) + if (!list.length) return null + + const total = firstNonZero(body?.tracks?.total_count, body?.tracks?.list_count, body?.track_count, list.length) + return { + source: 'joox' as const, + info: { + name: String(firstNonEmpty(body?.name, playlistId)), + img: pickJooxImage(body?.images) || null, + desc: String(firstNonEmpty(body?.description, body?.publish_date)), + author: '', + }, + list, + page, + limit, + total, + } +} + +/** 网页兜底:解析歌单 SSR 数据(上游 fetchPlaylistPageDataFromURL 的移植) */ +const parsePlaylistPageData = (nextData: any, pageUrl: string) => { + const pageProps = nextData?.props?.pageProps ?? {} + let detail: any = pageProps.playlistDetailList ?? {} + let tracks: any[] = pageProps.allPlaylistTracks?.tracks?.items ?? [] + let trackCount = firstNonZero( + pageProps.allPlaylistTracks?.tracks?.total_count, + pageProps.allPlaylistTracks?.tracks?.totalCount, + pageProps.allPlaylistTracks?.tracks?.list_count, + ) + if (!tracks.length) { + tracks = detail?.trackList ?? [] + trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count) + } + if (!tracks.length) { + detail = pageProps.content?.page?.playlistDetailList ?? {} + tracks = pageProps.content?.page?.allPlaylistTracks?.tracks?.items ?? [] + trackCount = firstNonZero( + pageProps.content?.page?.allPlaylistTracks?.tracks?.total_count, + pageProps.content?.page?.allPlaylistTracks?.tracks?.totalCount, + pageProps.content?.page?.allPlaylistTracks?.tracks?.list_count, + trackCount, + ) + if (!tracks.length) { + tracks = detail?.trackList ?? [] + trackCount = firstNonZero(trackCount, detail?.trackCount, detail?.totalCount, detail?.total_count, detail?.list_count) + } + } + if (!tracks.length) return null + + const id = normalizeJooxID(firstNonEmpty(detail?.id, detail?.listId, detail?.playlistId)) + const name = String(firstNonEmpty(detail?.name, detail?.title, detail?.playlistName, id)) + const cover = String(firstNonEmpty(detail?.imgSrc, detail?.cover, detail?.image, detail?.pic)) + const creator = firstNonEmpty( + detail?.creator, detail?.creatorName, detail?.userName, detail?.nickName, detail?.ownerName, + detail?.author, detail?.authorName, + detail?.user?.name, detail?.user?.userName, detail?.user?.nickName, detail?.user?.nick, + detail?.owner?.name, detail?.owner?.userName, detail?.owner?.nickName, detail?.owner?.nick, + detail?.creatorInfo?.name, detail?.creatorInfo?.userName, detail?.creatorInfo?.nickName, detail?.creatorInfo?.nick, + ) + const description = firstNonEmpty(detail?.description, detail?.intro, detail?.desc) + return { id, name, cover, creator, description, trackCount, tracks, pageUrl } +} + +/** 网页兜底:逐个区域尝试歌单页(上游 fetchPlaylistPageData / ParsePlaylist 的页面路径) */ +export const getPlaylistDetailFromPage = async(rawId: string) => { + const playlistId = normalizeJooxID(rawId) + if (!playlistId) throw new Error('joox 歌单 id 无效') + let lastErr: any = null + for (const pageUrl of jooxPlaylistLinks(playlistId)) { + try { + const { body: html } = await fetchJooxPage(pageUrl) + const nextData = extractJooxNextData(html) + const parsed = parsePlaylistPageData(nextData, pageUrl) + if (!parsed) throw new Error('joox 歌单页数据不存在') + const pageId = normalizeJooxID(parsed.id) || playlistId + const cover = String(parsed.cover || '') + const list = parsed.tracks + .map(item => jooxSongFromTrack(item, parsed.name, cover)) + .filter(Boolean) + if (!list.length) throw new Error('joox 歌单无歌曲') + return { + source: 'joox' as const, + info: { + name: String(parsed.name || pageId), + img: cover || null, + desc: String(parsed.description || ''), + author: String(parsed.creator || ''), + }, + list, + page: 1, + limit: list.length, + total: firstNonZero(parsed.trackCount, list.length), + } + } catch (err) { + lastErr = err + } + } + throw lastErr ?? new Error('joox 歌单页数据不存在') +} + +/** 歌单详情:openjoox2 通道优先,网页兜底(上游两条路都保留) */ +export const getPlaylistDetail = async(rawId: string, page = 1, limit = 50) => { + const playlistId = normalizeJooxID(rawId) + if (!playlistId) throw new Error('joox 歌单 id 无效') + try { + const detail = await fetchPlaylistTracks(playlistId, page, limit) + if (detail) return detail + } catch { + // 通道不可用时走网页兜底 + } + return getPlaylistDetailFromPage(playlistId) +} + +/** 歌单曲目(上游 GetPlaylistSongs:仅曲目列表;v3 接口 404 时用网页兜底) */ +export const getPlaylistSongs = async(id: string, page = 1, limit = 50) => { + try { + const detail = await fetchPlaylistTracks(normalizeJooxID(id), page, limit) + if (detail) return detail.list + } catch { + // 走网页兜底 + } + const { list } = await getPlaylistDetailFromPage(normalizeJooxID(id)) + return list +} + +/** 歌单搜索(上游 SearchPlaylist) */ +export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => { + const url = `${SEARCH_URL}?country=sg&lang=zh_cn&keyword=${encodeURIComponent(String(keyword ?? ''))}` + const { statusCode, body } = await httpFetch(url, { headers: jooxHeaders(), timeout: 20000 }).promise as any + if (statusCode != 200 || !body?.section_list) { + return { list: [], total: 0, page, limit, source: 'joox' as const } + } + + const list: any[] = [] + const seen = new Set() + for (const section of body.section_list) { + for (const item of section?.item_list ?? []) { + if (Number(item?.type) !== 1) continue + const summary = mapPlaylistSummary(item?.editor_playlist) + if (!summary || seen.has(summary.id)) continue + seen.add(summary.id) + list.push(summary) + } + } + + return { list: list.slice(0, limit), total: list.length, page, limit, source: 'joox' as const } +} + +/** 歌单分类页原始数据(上游 fetchJooxPlaylistCategoriesPage) */ +const fetchPlaylistCategoriesPage = async() => { + const { body: html } = await fetchJooxPage(CATEGORY_PAGE) + const nextData = extractJooxNextData(html) + const mlList = nextData?.props?.pageProps?.mlList + if (!mlList?.category?.length) throw new Error('joox 歌单分类不存在') + return mlList +} + +/** 分类 id:上游 jooxCategoryPlaylistID(数字/字符串都归一) */ +const categoryPlaylistId = (value: unknown): string => { + if (value == null) return '' + if (typeof value === 'number') return String(Math.trunc(value)) + return normalizeJooxID(value) +} + +/** 分类列表(上游 GetPlaylistCategories;首个为「全部」) */ +export const getCategories = async() => { + const data = await fetchPlaylistCategoriesPage() + const list: any[] = [{ id: '', name: '全部', group: '全部', hot: false }] + data.category.forEach((category: any, index: number) => { + const name = decodeJooxBase64Text(category?.title) + const items: any[] = category?.itemlist ?? [] + if (!name || !items.length) return + list.push({ id: String(index), name, group: 'JOOX', hot: false, count: items.length }) + }) + if (list.length === 1) throw new Error('joox 歌单分类不存在') + return { source: 'joox' as const, list } +} + +/** 分类下歌单(上游 GetCategoryPlaylists) */ +export const getCategoryPlaylists = async(categoryId = '', page = 1, limit = 20) => { + const data = await fetchPlaylistCategoriesPage() + const cid = String(categoryId ?? '').trim() + let items: any[] = [] + let categoryName = '全部' + + if (!cid) { + const seen = new Set() + for (const category of data.category) { + for (const item of category?.itemlist ?? []) { + const id = categoryPlaylistId(item?.id) + if (!id || seen.has(id)) continue + seen.add(id) + items.push(item) + } + } + } else { + for (let index = 0; index < data.category.length; index++) { + const category = data.category[index] + const name = decodeJooxBase64Text(category?.title) + if (String(index) === cid || (name && name.toLowerCase() === cid.toLowerCase())) { + items = category?.itemlist ?? [] + categoryName = name + break + } + } + if (!items.length) throw new Error('joox 歌单分类不存在') + } + + const start = Math.max(0, (page - 1) * limit) + if (start >= items.length) throw new Error('joox 分类下无歌单') + const pageItems = items.slice(start, start + limit) + + const list = pageItems + .map(item => { + const id = categoryPlaylistId(item?.id) + const name = decodeJooxBase64Text(item?.title) + if (!id || !name) return null + return { + id, + name, + cover: String(item?.picurl ?? '').replace('%d', '300') || null, + trackCount: 0, + creator: '', + description: '', + link: jooxPlaylistLink(id), + categoryId: cid, + categoryName, + source: 'joox' as const, + } + }) + .filter(Boolean) + if (!list.length) throw new Error('joox 分类下无歌单') + + return { list, total: items.length, page, limit, source: 'joox' as const } +} + +/** 歌单链接解析(上游 ParsePlaylist) */ +export const parsePlaylist = async(link: string) => { + const patterns = [ + /joox\.com\/.*\/playlist\/([^/?#]+)/, + /(?:playlistid|playlist_id|id)=([^&]+)/, + ] + for (const pattern of patterns) { + const m = pattern.exec(String(link ?? '')) + if (m) return getPlaylistDetail(m[1]) + } + const raw = String(link ?? '').trim() + if (raw.length > 8 && !raw.includes('/')) return getPlaylistDetail(raw) + throw new Error('invalid joox playlist link') +} + +export default { + getPlaylistDetail, + getPlaylistSongs, + searchPlaylist, + getCategories, + getCategoryPlaylists, + parsePlaylist, +} diff --git a/sdk/joox/song.ts b/sdk/joox/song.ts new file mode 100644 index 0000000..c7119aa --- /dev/null +++ b/sdk/joox/song.ts @@ -0,0 +1,73 @@ +// JOOX 歌曲形状映射(统一 Song 形状,见 tmp/port-spec.md) +// 兼容来源:openjoox2 track/album/playlist 条目、openjoox v3 search 的 song_info、 +// 以及 SSR 页面上的 track 条目(字段有 snake/camel 两套命名,上游 Go 也两套都兜底)。 + +import { formatPlayTime } from '../common/utils.ts' +import { + normalizeJooxID, + pickJooxImage, + jooxArtists, + firstNonEmpty, + firstNonZero, +} from './util.ts' + +/** 详情/列表 track 条目 → 统一 Song 形状 */ +export const jooxSongFromTrack = ( + item: any, + fallbackAlbum = '', + fallbackCover = '', + fallbackAlbumId = '', +): any | null => { + const songmid = normalizeJooxID(item?.id) + if (!songmid) return null + + const artists = Array.isArray(item?.artist_list) && item.artist_list.length + ? item.artist_list + : item?.artistList + const albumId = normalizeJooxID(firstNonEmpty(item?.album_id, item?.albumId, fallbackAlbumId)) + + return { + songmid, + name: String(firstNonEmpty(item?.name, item?.title, 'Unknown')), + singer: String(firstNonEmpty(jooxArtists(artists), 'Unknown')), + albumId: String(albumId || ''), + albumName: String(firstNonEmpty(item?.album_name, item?.albumName, fallbackAlbum)), + interval: formatPlayTime(firstNonZero(item?.play_duration, item?.playDuration, item?.duration)), + img: String(firstNonEmpty( + pickJooxImage(item?.images), + item?.imgSrc, + item?.cover, + item?.image, + item?.pic, + fallbackCover, + )) || null, + lrc: null, + vip: Number(item?.vip_flag ?? 0) === 1, + types: [], + _types: {}, + source: 'joox' as const, + } +} + +/** openjoox v3 搜索歌曲条目(song_info)→ 统一 Song 形状 */ +export const jooxSongFromSearch = (info: any): any | null => jooxSongFromTrack(info) + +/** 详情接口返回的整曲信息(parse 用,含可播放地址)→ 统一 Song 形状 */ +export const jooxSongFromDetail = (detail: any, playUrl = ''): any => { + const song = jooxSongFromTrack(detail) ?? { + songmid: normalizeJooxID(detail?.id), + name: String(detail?.name ?? ''), + singer: String(jooxArtists(detail?.artist_list)), + albumId: String(normalizeJooxID(detail?.album_id)), + albumName: String(detail?.album_name ?? ''), + interval: formatPlayTime(firstNonZero(detail?.play_duration)), + img: String(pickJooxImage(detail?.images)) || null, + lrc: null, + types: [], + _types: {}, + source: 'joox' as const, + } + if (playUrl) song.url = playUrl + song.vip = Number(detail?.vip_flag ?? 0) === 1 + return song +} diff --git a/sdk/joox/util.ts b/sdk/joox/util.ts new file mode 100644 index 0000000..4bf89ce --- /dev/null +++ b/sdk/joox/util.ts @@ -0,0 +1,181 @@ +// JOOX 平台公共工具:请求头/签名/ID 归一化/链接构造/页面数据提取 +// 背景(2026-10-02 实测): +// - JOOX 是东南亚服务,www.joox.com 会按来源 IP 重定向到 /intl;上游 Go 通过伪造 +// X-Forwarded-For 为东南亚 IP(36.73.34.109)绕过,本移植保持一致。 +// - 新版 web 播放器走 cache.api.joox.com/openjoox2/v1/*,必须带 secret 签名: +// secret = md5('Jo0x@t3Nc3nT' + 参数按插入序 k=v&... )(值经 encodeURIComponent), +// 密钥来自官网 JS bundle 常量 secretAPIKey(web 公开常量,非账号密钥)。 +// - 旧接口 api.joox.com/web-fcgi-bin/web_get_songinfo 已 404(取流/歌曲详情改用 openjoox2), +// 但 web_lyric 仍可用(歌词仍走上游接口)。 +// - 上游 New(cookie):cookie 用于游客态/登录态请求;登录 cookie 里的 wmid/session_key +// 在签名参数里作为 uid/usk 参与(可拿到完整曲目/更高档位)。 + +import crypto from 'node:crypto' +import { httpFetch } from '../request.ts' +import { getCredentials } from '../auth/store.ts' + +export const JOOX_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36' +/** 上游 Go 的默认游客 Cookie(jaox.go 常量 Cookie) */ +export const JOOX_DEFAULT_COOKIE = 'wmid=142420656; user_type=1; country=id; session_key=2a5d97d05dc8fe238150184eaf3519ad;' +/** 上游 Go 的 X-Forwarded-For(东南亚 IP,绕过地区重定向) */ +export const JOOX_XFF = '36.73.34.109' +/** openjoox2 签名密钥(官网 web bundle 常量 secretAPIKey) */ +export const JOOX_SECRET_KEY = 'Jo0x@t3Nc3nT' + +export const md5Hex = (str: string): string => crypto.createHash('md5').update(str, 'utf8').digest('hex') + +/** + * openjoox2 签名:md5(secretAPIKey + `k=encodeURIComponent(v)` 按插入序以 & 连接) + * 注意键序即对象插入序(服务端按同序校验),不要重排参数。 + */ +export const jooxSign = (params: Record): string => + md5Hex(JOOX_SECRET_KEY + Object.keys(params).map(key => `${key}=${encodeURIComponent(String(params[key]))}`).join('&')) + +/** 绑定到 sdk/auth/store.ts 的登录 cookie(未登录返回空串,不回退游客 cookie) */ +export const jooxLoginCookie = (): string => { + const cred = getCredentials('joox') as { cookie?: string } | null + return cred?.cookie || '' +} + +/** 请求用 cookie(登录态优先;未登录回退上游游客 cookie) */ +export const jooxCookie = (): string => jooxLoginCookie() || JOOX_DEFAULT_COOKIE + +/** 从 cookie 串里取一个字段 */ +export const cookieValue = (cookie: string, name: string): string => { + const m = new RegExp(`(?:^|;\\s*)${name}=([^;]*)`).exec(String(cookie ?? '')) + return m ? m[1].trim() : '' +} + +/** 平台公共请求头(含地区伪装与 Cookie) */ +export const jooxHeaders = (extra: Record = {}): Record => ({ + 'User-Agent': JOOX_UA, + Cookie: jooxCookie(), + 'X-Forwarded-For': JOOX_XFF, + Referer: 'https://www.joox.com/', + Accept: 'application/json, text/plain, */*', + ...extra, +}) + +/** 语言归一(对齐官网 51bm.c:zh_hk/zh_mo → zh_TW,zh_cn → zh_CN) */ +export const normalizeJooxLang = (lang?: string): string => { + if (lang === 'zh_hk' || lang === 'zh_mo') return 'zh_TW' + if (lang === 'zh_cn') return 'zh_CN' + return lang || 'zh_TW' +} + +/** ID 归一化(上游 normalizeJooxID):去空白 + 百分号解码(空格还原为 +) */ +export const normalizeJooxID = (raw: unknown): string => { + let id = String(raw ?? '').trim() + if (!id) return '' + if (id.includes('%')) { + try { + id = decodeURIComponent(id) + } catch { + // 上游 PathUnescape/QueryUnescape 失败时保留原值 + } + id = id.replace(/ /g, '+') + } + return id +} + +export const jooxSongLink = (id: unknown): string => `https://www.joox.com/hk/single/${normalizeJooxID(id)}` +export const jooxAlbumLink = (id: unknown): string => `https://www.joox.com/hk/album/${normalizeJooxID(id)}` +export const jooxRegionalPlaylistLink = (region: string, id: unknown): string => + `https://www.joox.com/${region}/playlist/${normalizeJooxID(id)}` +export const jooxPlaylistLink = (id: unknown): string => jooxRegionalPlaylistLink('id', id) +/** 歌单页候选(上游顺序:id → sg → hk,逐个尝试直到页面数据可用) */ +export const jooxPlaylistLinks = (id: unknown): string[] => + ['id', 'sg', 'hk'].map(region => jooxRegionalPlaylistLink(region, id)) + +/** 封面挑选(上游 pickJooxImage:优先 300 → 1000 → 100,再回退首个非空) */ +export const pickJooxImage = (images: any[]): string => { + const list = Array.isArray(images) ? images : [] + for (const width of [300, 1000, 100]) { + for (const image of list) { + const url = String(image?.url ?? '').trim() + if (Number(image?.width) === width && url) return url + } + } + for (const image of list) { + const url = String(image?.url ?? '').trim() + if (url) return url + } + return '' +} + +/** 歌手拼接(上游 joinJooxArtists,分隔符 ' / ') */ +export const jooxArtists = (list: any[]): string => + (Array.isArray(list) ? list : []).map(a => String(a?.name ?? '').trim()).filter(Boolean).join(' / ') + +export const firstNonEmpty = (...values: any[]): any => { + for (const value of values) { + if (value != null && String(value).trim() !== '') return value + } + return '' +} + +export const firstNonZero = (...values: any[]): number => { + for (const value of values) { + const n = Number(value) + if (Number.isFinite(n) && n > 0) return n + } + return 0 +} + +/** base64 文本解码(上游 decodeJooxBase64Text:容忍 URL-safe 与缺失填充) */ +export const decodeJooxBase64Text = (value: unknown): string => { + const raw = String(value ?? '').trim() + if (!raw) return '' + const normalized = raw.replace(/-/g, '+').replace(/_/g, '/') + const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4) + try { + const text = Buffer.from(padded, 'base64').toString('utf8') + // 解码结果是二进制噪声时回退原值(对齐上游 stdout base64 校验语义) + return text.includes('\uFFFD') ? raw : text.trim() + } catch { + return raw + } +} + +/** HTML 实体解码(歌词文本兜底) */ +export const decodeHtmlEntities = (text: unknown): string => + String(text ?? '') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/�*39;/g, "'") + .replace(/ /g, ' ') + +/** 歌词解码:base64 → utf8 → HTML 实体(上游 base64 + 常见实体兜底) */ +export const decodeJooxLyric = (value: unknown): string => decodeHtmlEntities(decodeJooxBase64Text(value)) + +/** 从 SSR 页面提取 __NEXT_DATA__ JSON */ +export const extractJooxNextData = (html: unknown): any => { + const m = /]*id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/.exec(String(html ?? '')) + if (!m) return null + try { + return JSON.parse(m[1]) + } catch { + return null + } +} + +/** + * 拉取页面并手动跟随 3xx(request.ts 不自动跟跳;Go 客户端会自动跟) + * 返回最终页 HTML(非字符串 body 视为空串) + */ +export const fetchJooxPage = async (url: string, maxRedirect = 3): Promise<{ statusCode: number, body: string, finalUrl: string }> => { + let current = url + for (let i = 0; i <= maxRedirect; i++) { + const res: any = await httpFetch(current, { headers: jooxHeaders(), timeout: 20000 }).promise + const location = res?.headers?.location + if (res.statusCode >= 300 && res.statusCode < 400 && location) { + current = new URL(String(location), current).href + continue + } + return { statusCode: res.statusCode, body: typeof res.body === 'string' ? res.body : '', finalUrl: current } + } + throw new Error('joox 页面重定向次数过多') +} diff --git a/sdk/migu/album.ts b/sdk/migu/album.ts new file mode 100644 index 0000000..c301a13 --- /dev/null +++ b/sdk/migu/album.ts @@ -0,0 +1,130 @@ +// 咪咕专辑 —— 搜索(search_all.do album 开关)+ 曲目(queryAlbumSong,分页聚合去重)+ 详情(resourceinfo 2003) +// 上游对应:music-lib/migu/album.go、migu.go 的 fetchAlbumDetail / fetchAlbumSongs +import { + buildUrl, convertItemToSong, firstNonEmpty, miguGet, normalizeMiguImageURL, + pickMiguImage, type MiguImageItem, type MiguSong, type MiguSongItem, miguAlbumLink, +} from './util.ts' +import { searchAll } from './musicSearch.ts' + +interface MiguAlbumSearchItem { + id?: string + resourceType?: string + name?: string + singer?: string + publishDate?: string + desc?: string + imgItems?: MiguImageItem[] +} + +export const searchAlbum = async(keyword: string, page = 1, limit = 10) => { + const body = await searchAll(keyword, 'album', page, limit) + const result: MiguAlbumSearchItem[] = body?.albumResultData?.result ?? [] + const list = result.map(item => { + const albumID = String(item.id ?? '').trim() + if (!albumID) return null + return { + id: albumID, + name: String(item.name ?? '').trim(), + cover: pickMiguImage(item.imgItems), + creator: String(item.singer ?? '').trim(), + description: firstNonEmpty(item.desc, item.publishDate), + link: miguAlbumLink(albumID), + source: 'migu' as const, + } + }).filter(Boolean) + return { list, source: 'migu' as const, page, limit, total: list.length } +} + +/** 专辑曲目(每页 50 全量翻页 + 去重;上游 fetchAlbumSongs) */ +export const fetchAlbumSongs = async(albumId: string): Promise<{ songs: MiguSong[], total: number }> => { + const albumID = String(albumId ?? '').trim() + if (!albumID) throw new Error('migu 专辑 id 为空') + const pageSize = 50 + const seen = new Set() + const songs: MiguSong[] = [] + let totalCount = 0 + + for (let pageNo = 1; ; pageNo++) { + const body = await miguGet(buildUrl('https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/queryAlbumSong', { + albumId: albumID, pageNo, pageSize, + })) + if (body?.code && body.code !== '000000') throw new Error(`migu api error: ${body.info} (code ${body.code})`) + const songList: MiguSongItem[] = body?.data?.songList ?? [] + if (!totalCount) totalCount = Number(body?.data?.totalCount ?? 0) || 0 + if (!songList.length) break + + const before = songs.length + for (const item of songList) { + const song = convertItemToSong(item, true) + if (!song) continue + const key = firstNonEmpty(song.extra.content_id, item.contentId, song.songmid, item.copyrightId) + || `${song.name}|${song.singer}` + if (seen.has(key)) continue + seen.add(key) + songs.push(song) + } + + if (songList.length < pageSize) break + if (totalCount > 0 && songs.length >= totalCount) break + if (songs.length === before) break + } + + if (!songs.length) throw new Error('migu 专辑没有可播放歌曲') + return { songs, total: totalCount || songs.length } +} + +/** 专辑详情(曲目 + resourceinfo 2003 的元数据),对齐既有 kg/kw 的 getAlbumDetail 返回形状 */ +export const getAlbumDetail = async(id: string, page = 1, limit = 50) => { + const albumID = String(id ?? '').trim() + if (!albumID) throw new Error('migu 专辑 id 为空') + const { songs, total } = await fetchAlbumSongs(albumID) + + let info: { name: string, img: string, desc: string, author: string } = { name: albumID, img: '', desc: '', author: '' } + try { + const body = await miguGet(buildUrl('https://app.c.nf.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do', { + needSimple: '00', resourceType: '2003', resourceId: albumID, + })) + if (body?.code && body.code !== '000000') throw new Error(body.info) + const res = body?.resource?.[0] + if (res) { + info = { + name: String(res.title ?? '').trim() || albumID, + img: pickMiguImage(res.imgItems), + desc: String(res.summary ?? '').trim(), + author: String(res.singer ?? '').trim(), + } + if (!info.img && songs[0]?.img) info.img = songs[0].img! + } + } catch { + if (songs[0]?.img) info.img = songs[0].img! + } + + const start = (page - 1) * limit + return { + source: 'migu' as const, + info, + list: songs.slice(start, start + limit), + page, + limit, + total: total || songs.length, + } +} + +export const getAlbumSongs = async(id: string) => (await fetchAlbumSongs(id)).songs + +const ALBUM_PATTERNS = [ + /music\.migu\.cn\/(?:v3|v5)\/music\/album\/(\d+)/, + /albumId=(\d+)/, + /resourceId=(\d+)/, +] + +export const parseAlbumLink = async(link: string) => { + for (const pattern of ALBUM_PATTERNS) { + const m = String(link ?? '').match(pattern) + if (m) return getAlbumDetail(m[1]!) + } + throw new Error('无效的咪咕专辑链接') +} + +export { normalizeMiguImageURL } +export default { searchAlbum, getAlbumDetail, getAlbumSongs, parseAlbumLink } diff --git a/sdk/migu/index.ts b/sdk/migu/index.ts new file mode 100644 index 0000000..4f3461b --- /dev/null +++ b/sdk/migu/index.ts @@ -0,0 +1,63 @@ +// 咪咕音乐平台模块 —— 搜索 / 取流(多档候选 + Z3D 解密标注)/ 歌词 / 封面 / 专辑 / 歌单 / 链接解析 +// 上游对应:music-lib/migu/(migu.go、song.go、lyric.go、album.go、playlist.go、download.go) +// 匿名面(2026-10-02 实测):搜索、PQ(128k mp3) 取流、歌词、封面、专辑曲目、歌单曲目均可用; +// HQ/SQ/ZQ 各档需登录 Cookie(接口返回「登录后免费试听」)。 +import miguApi from '../apis/migu.ts' +import musicSearch from './musicSearch.ts' +import lyric, { contentIdOf } from './lyric.ts' +import album from './album.ts' +import playlist from './playlist.ts' +import { fetchSongDetail, miguSongLink } from './util.ts' + +const migu = { + musicSearch, + getMusicUrl(songInfo: any, type: string) { + return miguApi.getMusicUrl(songInfo, type) + }, + getLyric(songInfo: any) { + return lyric.getLyric(songInfo) + }, + getPic(songInfo: any) { + return (async() => { + if (songInfo?.img) return songInfo.img + const contentID = contentIdOf(songInfo) + if (!contentID) return null + const item = await fetchSongDetail(contentID) + const { pickMiguImage, normalizeMiguImageURL, firstNonEmpty } = await import('./util.ts') + return normalizeMiguImageURL(firstNonEmpty( + pickMiguImage(item.imgItems), pickMiguImage(item.albumImgs), item.img1, item.img2, item.img3, + )) || null + })() + }, + getMusicInfo(songInfo: any) { + const contentID = contentIdOf(songInfo) + if (!contentID) return Promise.reject(new Error('migu 歌曲 id 无效')) + return fetchSongDetail(contentID) + }, + album, + playlist, + parse: { + parseSong: async(link: string) => parseSong(link), + parseAlbum: album.parseAlbumLink, + parsePlaylist: playlist.parsePlaylistLink, + }, + getMusicDetailPageUrl(songInfo: any) { + const extra = songInfo?.extra ?? {} + return String(extra.link ?? '') || miguSongLink(contentIdOf(songInfo)) + }, +} + +/** 歌曲链接解析:https://music.migu.cn/v3/music/song/(含裸 contentId) */ +export const parseSong = async(link: string) => { + const raw = String(link ?? '') + const m = raw.match(/music\.migu\.cn\/v3\/music\/song\/(\d+)/) || raw.match(/^(\d{6,})$/) + if (!m) throw new Error('无效的咪咕歌曲链接') + const contentID = m[1]! + const item = await fetchSongDetail(contentID) + const { convertItemToSong } = await import('./util.ts') + const song = convertItemToSong(item, true) + if (!song) throw new Error('该歌曲没有可用格式') + return song +} + +export default migu diff --git a/sdk/migu/lyric.ts b/sdk/migu/lyric.ts new file mode 100644 index 0000000..fb0336b --- /dev/null +++ b/sdk/migu/lyric.ts @@ -0,0 +1,42 @@ +// 咪咕歌词 —— resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本 +// 上游对应:music-lib/migu/lyric.go +import { buildUrl, fetchSongDetail, miguGet, UserAgent } from './util.ts' +import { httpFetch } from '../request.ts' + +const LRC_REFERER = 'https://y.migu.cn/' +const LRC_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' + +/** 从 songmid(contentId|resourceType|formatType)或 extra 取 contentId */ +export const contentIdOf = (songInfo: any): string => { + const extra = songInfo?.extra ?? {} + if (extra.content_id) return String(extra.content_id) + const id = String(songInfo?.songmid ?? songInfo?.id ?? '') + return id.split('|')[0] ?? '' +} + +export const getLyric = (songInfo: any) => { + const requestObj = { cancelHttp() {}, promise: null as any } + const contentID = contentIdOf(songInfo) + requestObj.promise = (async() => { + if (!contentID) throw new Error('migu 歌曲 id 无效') + const body = await miguGet(buildUrl('http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do', { + resourceId: contentID, + resourceType: '2', + })) + const resource = body?.resource ?? [] + if (!resource.length) throw new Error('migu 歌词资源不存在') + let lrcUrl = String(resource[0]?.lrcUrl ?? '') || String(resource[0]?.lyricUrl ?? '') + if (!lrcUrl) return { lyric: '', tlyric: '' } + lrcUrl = lrcUrl.replace(/^http:\/\//, 'https://') + const { statusCode, body: lrc } = await httpFetch(lrcUrl, { + headers: { 'User-Agent': LRC_UA, Referer: LRC_REFERER }, + timeout: 20000, + }).promise + if (statusCode !== 200) throw new Error(`migu 歌词下载失败 ${statusCode}`) + return { lyric: typeof lrc === 'string' ? lrc : '', tlyric: '' } + })() + return requestObj +} + +export { fetchSongDetail } +export default { getLyric } diff --git a/sdk/migu/musicSearch.ts b/sdk/migu/musicSearch.ts new file mode 100644 index 0000000..52ca978 --- /dev/null +++ b/sdk/migu/musicSearch.ts @@ -0,0 +1,44 @@ +// 咪咕音乐搜索 —— search_all.do(Android_migu 参数),searchSwitch 控制返回类别 +// 上游对应:music-lib/migu/migu.go 的 (*Migu).Search +import { buildUrl, convertItemToSong, miguGet, type MiguSongItem } from './util.ts' + +export const SEARCH_ALL_URL = 'http://pd.musicapp.migu.cn/MIGUM2.0/v1.0/content/search_all.do' + +/** 搜索开关:只开某一类(song/album/songlist),bestShow 保留最优推荐 */ +export const searchSwitch = (kind: 'song' | 'album' | 'songlist'): string => JSON.stringify({ + song: kind === 'song' ? 1 : 0, + album: kind === 'album' ? 1 : 0, + singer: 0, + tagSong: 0, + mvSong: 0, + songlist: kind === 'songlist' ? 1 : 0, + bestShow: 1, +}) + +export const searchAll = async(text: string, kind: 'song' | 'album' | 'songlist', page = 1, limit = 20): Promise => { + const url = buildUrl(SEARCH_ALL_URL, { + ua: 'Android_migu', + version: '5.0.1', + text, + pageNo: page, + pageSize: limit, + searchSwitch: searchSwitch(kind), + }) + return miguGet(url) +} + +export const search = async(keyword: string, page = 1, limit = 20) => { + const body = await searchAll(keyword, 'song', page, limit) + const result: MiguSongItem[] = body?.songResultData?.result ?? [] + const list = result.map(item => convertItemToSong(item, true)).filter((s): s is NonNullable => s != null) + const total = parseInt(String(body?.songResultData?.totalCount ?? list.length), 10) || list.length + return { + list, + allPage: Math.max(1, Math.ceil(total / limit)), + total, + limit, + source: 'migu' as const, + } +} + +export default { search } diff --git a/sdk/migu/playlist.ts b/sdk/migu/playlist.ts new file mode 100644 index 0000000..54b98bc --- /dev/null +++ b/sdk/migu/playlist.ts @@ -0,0 +1,153 @@ +// 咪咕歌单 —— 搜索(search_all.do songlist 开关)、曲目(MIGUM3.0 playlist/song/v2.0)、 +// 分类(静态关键词表,上游同款,平台未提供分类接口)、链接解析 +// 上游对应:music-lib/migu/playlist.go +import { + buildUrl, convertItemToSong, firstNonEmpty, miguGet, miguPlaylistLink, + pickMiguImage, type MiguImageItem, type MiguSong, type MiguSongItem, +} from './util.ts' +import { searchAll } from './musicSearch.ts' + +interface MiguPlaylistItem { + id?: string + name?: string + musicNum?: string + userName?: string + ownerName?: string + musicListPicUrl?: string + playNum?: string + resourceType?: string + imgItems?: MiguImageItem[] +} + +const toPlaylist = (item: MiguPlaylistItem, categoryId?: string) => { + const playlistID = String(item.id ?? '').trim() + const name = String(item.name ?? '').trim() + if (!playlistID || !name) return null + return { + id: playlistID, + name, + cover: firstNonEmpty(item.musicListPicUrl, pickMiguImage(item.imgItems)), + trackCount: parseInt(String(item.musicNum ?? ''), 10) || 0, + playCount: parseInt(String(item.playNum ?? ''), 10) || 0, + creator: firstNonEmpty(item.userName, item.ownerName), + link: miguPlaylistLink(playlistID), + source: 'migu' as const, + ...(categoryId ? { categoryId } : {}), + } +} + +/** 歌单搜索(上游 searchPlaylists);分类歌单也用同一接口,关键词即分类名 */ +export const searchPlaylist = async(keyword: string, page = 1, limit = 20) => { + const body = await searchAll(keyword, 'songlist', page, limit) + const result: MiguPlaylistItem[] = body?.songListResultData?.result ?? [] + const list = result.map(item => toPlaylist(item)).filter(Boolean) + return { list, source: 'migu' as const, page, limit, total: list.length } +} + +/** 静态分类(上游 miguPlaylistCategoryKeywords) */ +const CATEGORY_KEYWORDS = [ + { name: '华语', group: '语种', hot: true }, + { name: '欧美', group: '语种', hot: true }, + { name: '日语', group: '语种' }, + { name: '韩语', group: '语种' }, + { name: '粤语', group: '语种' }, + { name: '流行', group: '风格', hot: true }, + { name: '摇滚', group: '风格', hot: true }, + { name: '民谣', group: '风格', hot: true }, + { name: '电子', group: '风格' }, + { name: '说唱', group: '风格' }, + { name: '古风', group: '风格' }, + { name: '轻音乐', group: '风格' }, + { name: '影视', group: '场景', hot: true }, + { name: 'ACG', group: '场景' }, + { name: '治愈', group: '场景' }, + { name: '运动', group: '场景' }, + { name: '学习', group: '场景' }, + { name: '睡前', group: '场景' }, +] + +export const getCategories = () => ({ + source: 'migu' as const, + list: [ + { id: '', name: '全部', group: '全部' }, + ...CATEGORY_KEYWORDS.map(c => ({ id: c.name, name: c.name, group: c.group, hot: Boolean(c.hot) })), + ], +}) + +export const getCategoryPlaylists = async(categoryId: string, page = 1, limit = 20) => { + const keyword = String(categoryId ?? '').trim() || '华语' + const { list } = await searchPlaylist(keyword, page, limit) + return { list: list.map(p => toPlaylist(p as any, keyword)).filter(Boolean), source: 'migu' as const, page, limit } +} + +/** 歌单曲目(每页 50 全量翻页 + 去重;上游 GetPlaylistSongs) */ +export const fetchPlaylistSongs = async(playlistId: string): Promise<{ songs: MiguSong[], total: number }> => { + const playlistID = String(playlistId ?? '').trim() + if (!playlistID) throw new Error('migu 歌单 id 为空') + const pageSize = 50 + const seen = new Set() + const songs: MiguSong[] = [] + let totalCount = 0 + + for (let pageNo = 1; ; pageNo++) { + const body = await miguGet(buildUrl('https://app.c.nf.migu.cn/MIGUM3.0/resource/playlist/song/v2.0', { + pageNo, pageSize, playlistId: playlistID, + })) + if (body?.code && body.code !== '000000') throw new Error(`migu api error: ${body.info} (code ${body.code})`) + const songList: MiguSongItem[] = body?.data?.songList ?? [] + if (!totalCount) totalCount = Number(body?.data?.totalCount ?? 0) || 0 + if (!songList.length) break + + const before = songs.length + for (const item of songList) { + const song = convertItemToSong(item, true) + if (!song) continue + const key = firstNonEmpty(song.extra.content_id, item.contentId, song.songmid, item.copyrightId) + || `${song.name}|${song.singer}` + if (seen.has(key)) continue + seen.add(key) + songs.push(song) + } + + if (songList.length < pageSize) break + if (totalCount > 0 && songs.length >= totalCount) break + if (songs.length === before) break + } + + if (!songs.length) throw new Error('migu 歌单没有可播放歌曲') + return { songs, total: totalCount || songs.length } +} + +export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => { + const playlistID = String(id ?? '').trim() + const { songs, total } = await fetchPlaylistSongs(playlistID) + const start = (page - 1) * limit + return { + source: 'migu' as const, + info: { name: playlistID, img: songs[0]?.img ?? '', desc: '', author: '' }, + list: songs.slice(start, start + limit), + page, + limit, + total: total || songs.length, + } +} + +export const getPlaylistSongs = async(id: string) => (await fetchPlaylistSongs(id)).songs + +const PLAYLIST_PATTERNS = [ + /playlistId=(\d+)/, + /musicListId=(\d+)/, + /(?:playlist|songlist)\/(\d+)/, +] + +export const parsePlaylistLink = async(link: string) => { + const raw = String(link ?? '') + for (const pattern of PLAYLIST_PATTERNS) { + const m = raw.match(pattern) + if (m) return getPlaylistDetail(m[1]!) + } + if (raw && !raw.includes('/')) return getPlaylistDetail(raw) + throw new Error('无效的咪咕歌单链接') +} + +export default { searchPlaylist, getPlaylistDetail, getPlaylistSongs, getCategories, getCategoryPlaylists, parsePlaylistLink } diff --git a/sdk/migu/util.ts b/sdk/migu/util.ts new file mode 100644 index 0000000..b388d4f --- /dev/null +++ b/sdk/migu/util.ts @@ -0,0 +1,433 @@ +// 咪咕音乐(migu)公共层 —— 常量、请求、条目 → 歌曲的统一转换、档位与解密工具 +// 上游对应:music-lib/migu/migu.go(convertItemToSong*、collectMiguFormats、pickMiguImage 等) +// 说明:匿名态 listen-url 仅放行 PQ(128k mp3);HQ/SQ/ZQ 各档需携带登录 Cookie(对话框「登录后免费试听」)。 +import crypto from 'node:crypto' +import { httpFetch } from '../request.ts' +import { getCredentials } from '../auth/store.ts' +import type { SongType } from '../types.ts' + +/** 咪咕 toneFlag → 平台音质标签(types 展示与取流返回共用) */ +export const TONE_QUALITY: Record = { + LQ: '64k', + PQ: '128k', + HQ: '320k', + SQ: 'flac', + ZQ: 'flac24bit', + ZQ24: 'flac24bit', + ZQ32: 'wav', + Z3D: 'wav', + I3D: 'flac24bit', + '3D60': 'wav', +} + +export const UserAgent = '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' +export const Referer = 'http://music.migu.cn/' + +/** listen-url 走 Android 客户端 UA/头,与网页通道不同 UA */ +export const AndroidUA = 'Android_migu/7.41.13 okhttp/3.12.13' +export const AndroidChannel = '0146832' +export const AndroidVersion = '7.41.13' + +/** Z3D 加密流的 app code 密文(AES-128-CBC,key 为 AppKey 前 16 字符) */ +const ANDROID_APP_KEY = '308202333082019c' +const ENCRYPTED_APP_CODE = Buffer.from([ + 0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55, + 0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF, + 0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A, + 0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83, +]) + +export const getMiguCookie = (): string => { + const cred = getCredentials('migu') as { cookie?: string } | null + return String(cred?.cookie ?? '').trim() +} + +/** 带 Referer/UA/Cookie 的 GET,返回解析后的 body(JSON 已解析) */ +export const miguGet = async(url: string, extraHeaders: Record = {}): Promise => { + const cookie = getMiguCookie() + const headers: Record = { 'User-Agent': UserAgent, Referer, ...extraHeaders } + if (cookie) headers.Cookie = cookie + const { statusCode, body } = await httpFetch(url, { headers, timeout: 20000 }).promise + if (statusCode !== 200) throw new Error(`migu http ${statusCode}`) + return body +} + +export const buildUrl = (base: string, params: Record): string => { + const qs = new URLSearchParams() + for (const [k, v] of Object.entries(params)) qs.set(k, String(v)) + return `${base}?${qs.toString()}` +} + +// ---------------- 数据形状(对齐上游 MiguSongItem 结构体) ---------------- + +export interface MiguImageItem { imgSizeType?: string, img?: string } +interface MiguArtistItem { id?: string, name?: string } +interface MiguAlbumItem { id?: string, name?: string } +export interface MiguRateFormat { + formatType?: string + resourceType?: string + size?: string + androidSize?: string + isize?: string + asize?: string + fileType?: string + androidFileType?: string + iformat?: string + aformat?: string + price?: string + showTag?: string[] + showTags?: string[] +} +export interface MiguZ3DCode { + formatType?: string + resourceType?: string + size?: string + androidSize?: string + isize?: string + asize?: string + fileType?: string + androidFileType?: string + iformat?: string + aformat?: string + price?: string + z3dKey?: string + z3dIv?: string + androidFileKey?: string + iosFileKey?: string + iosSize?: string + iosFileType?: string + url?: string + iosUrl?: string + androidUrl?: string + h5Url?: string +} +export interface MiguSongItem { + id?: string + name?: string + songName?: string + songId?: string + singers?: MiguArtistItem[] + artists?: MiguArtistItem[] + singerList?: MiguArtistItem[] + albums?: MiguAlbumItem[] + albumId?: string + album?: string + singer?: string + contentId?: string + copyrightId?: string + chargeAuditions?: string + downloadTags?: string[] + imgItems?: MiguImageItem[] + albumImgs?: MiguImageItem[] + newRateFormats?: MiguRateFormat[] + rateFormats?: MiguRateFormat[] + audioFormats?: MiguRateFormat[] + z3dCode?: MiguZ3DCode | null + img1?: string + img2?: string + img3?: string + duration?: number + vipType?: string +} + +// ---------------- 工具函数(1:1 对应上游) ---------------- + +export const firstNonEmpty = (...values: Array): string => { + for (const value of values) { + if (value != null && String(value).trim() !== '') return String(value) + } + return '' +} + +export const firstNonZeroString = (...values: Array): string => { + for (const value of values) { + const s = String(value ?? '').trim() + if (s !== '' && s !== '0') return s + } + return '' +} + +export const pickMiguImage = (items?: MiguImageItem[] | null): string => { + if (!Array.isArray(items)) return '' + for (const preferred of ['02', '01', '03']) { + for (const item of items) { + if (item?.imgSizeType === preferred && String(item?.img ?? '').trim() !== '') return String(item.img) + } + } + for (const item of items) { + if (String(item?.img ?? '').trim() !== '') return String(item.img) + } + return '' +} + +export const normalizeMiguImageURL = (image?: string | null): string => { + const url = String(image ?? '').trim() + if (!url) return '' + if (url.startsWith('http://') || url.startsWith('https://')) return url + if (url.startsWith('//')) return `https:${url}` + if (url.startsWith('/')) return `https://d.musicapp.migu.cn${url}` + return url +} + +export const collectMiguArtistNames = (item: MiguSongItem): string[] => { + const names: string[] = [] + const seen = new Set() + const append = (name?: string) => { + const n = String(name ?? '').trim() + if (!n || seen.has(n)) return + seen.add(n) + names.push(n) + } + for (const s of item.singers ?? []) append(s?.name) + for (const s of item.singerList ?? []) append(s?.name) + for (const a of item.artists ?? []) append(a?.name) + if (!names.length) for (const n of String(item.singer ?? '').split('|')) append(n) + return names +} + +/** 汇总三组 rateFormats + z3dCode,按 formatType|resourceType 去重(上游 collectMiguFormats) */ +export const collectMiguFormats = (item: MiguSongItem): MiguRateFormat[] => { + const groups: MiguRateFormat[][] = [ + item.newRateFormats ?? [], + item.rateFormats ?? [], + item.audioFormats ?? [], + ] + const code = item.z3dCode + if (code && code.formatType) { + groups.push([{ + formatType: code.formatType, + resourceType: code.resourceType, + size: code.size, + androidSize: code.androidSize, + isize: code.isize, + asize: code.asize, + fileType: code.fileType, + androidFileType: code.androidFileType, + iformat: code.iformat, + aformat: code.aformat, + price: code.price, + }]) + } + const formats: MiguRateFormat[] = [] + const seen = new Set() + for (const group of groups) { + for (const format of group) { + const key = `${String(format.formatType ?? '').trim().toUpperCase()}|${String(format.resourceType ?? '').trim()}` + if (key === '|') continue + if (seen.has(key)) continue + seen.add(key) + formats.push(format) + } + } + return formats +} + +export const miguFormatRank = (formatType?: string): number => { + switch (String(formatType ?? '').trim().toUpperCase()) { + case 'ZQ32': return 80 + case 'ZQ': case 'ZQ24': return 75 + case 'SQ': return 70 + case 'HQ': return 60 + case 'I3D': case 'Z3D': return 50 + case '3D60': return 40 + case 'PQ': return 30 + case 'LQ': return 20 + default: return 10 + } +} + +export const miguFormatExt = (formatType?: string, formatCode?: string): string => { + const type = String(formatType ?? '').trim().toUpperCase() + const code = String(formatCode ?? '').trim() + switch (type) { + case 'SQ': case 'ZQ': case 'ZQ24': return 'flac' + case 'ZQ32': case 'Z3D': case '3D60': return 'wav' + case 'I3D': return 'm4a' + case 'PQ': case 'HQ': case 'LQ': return 'mp3' + } + if (code.startsWith('011')) return 'flac' + return 'mp3' +} + +export const miguAlbumLink = (id: string): string => `https://music.migu.cn/v3/music/album/${id}` +export const miguPlaylistLink = (id: string): string => `https://music.migu.cn/v5/#/playlist?playlistId=${id}&playlistType=ordinary` +export const miguSongLink = (id: string): string => `https://music.migu.cn/v3/music/song/${id}` + +// ---------------- 条目 → 歌曲(上游 convertItemToSongWithOption) ---------------- + +export interface MiguSong { + songmid: string + name: string + singer: string + albumId: string + albumName: string + interval: string | number + img: string | null + lrc: string | null + types: SongType[] + _types: Record + payPlay?: boolean + /** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */ + extra: Record + source: 'migu' + [key: string]: unknown +} + +/** 把 "mm:ss"/秒数统一成上游 interval 形态(秒数)——路由层按原样透传 */ +const SECONDS_FROM_SIZE = (size: number, bitrate: number): number => (bitrate > 0 ? Math.floor(size * 8 / bitrate) : 0) + +export const convertItemToSong = (item: MiguSongItem, allowPaid = true): MiguSong | null => { + const artistNames = collectMiguArtistNames(item) + const songName = firstNonEmpty(item.name, item.songName) + let albumName = String(item.album ?? '').trim() + if (item.albums?.length && String(item.albums[0]?.name ?? '').trim() !== '') { + albumName = String(item.albums[0]!.name).trim() + } + + const rateFormats = collectMiguFormats(item) + if (!rateFormats.length) return null + + let duration = Number(item.duration ?? 0) || 0 + const candidates: Array<{ index: number, size: number, ext: string, rank: number, vip: boolean, tone: string }> = [] + + rateFormats.forEach((fmtItem, i) => { + const sizeStr = firstNonZeroString(fmtItem.androidSize, fmtItem.asize, fmtItem.size, fmtItem.isize) + const sizeVal = parseInt(sizeStr, 10) || 0 + let ext = firstNonEmpty(fmtItem.androidFileType, fmtItem.fileType) + if (!ext) ext = miguFormatExt(fmtItem.formatType, firstNonEmpty(fmtItem.aformat, fmtItem.iformat)) + + if (!duration && sizeVal > 0) { + let bitrate = 0 + switch (fmtItem.formatType) { + case 'PQ': bitrate = 128000; break + case 'HQ': bitrate = 320000; break + case 'LQ': bitrate = 64000; break + } + if (bitrate > 0) duration = SECONDS_FROM_SIZE(sizeVal, bitrate) + } + + const priceVal = parseInt(String(fmtItem.price ?? ''), 10) || 0 + const tags = fmtItem.showTag?.length ? fmtItem.showTag : (fmtItem.showTags ?? []) + let isVipTag = tags.some(tag => String(tag ?? '').trim().toLowerCase() === 'vip') + if (!isVipTag) { + isVipTag = (item.downloadTags ?? []).some(tag => String(tag ?? '').trim().toLowerCase() === 'vip') + } + const isHiddenPaid = item.chargeAuditions === '1' && priceVal >= 200 + + if (allowPaid || (!isVipTag && !isHiddenPaid)) { + candidates.push({ + index: i, size: sizeVal, ext, rank: miguFormatRank(fmtItem.formatType), + vip: isVipTag || isHiddenPaid, tone: String(fmtItem.formatType ?? ''), + }) + } + }) + + if (!candidates.length) return null + + candidates.sort((a, b) => (a.rank !== b.rank ? b.rank - a.rank : b.size - a.size)) + const best = candidates[0]! + const bestFormat = rateFormats[best.index]! + + const bitrate = duration > 0 && best.size > 0 ? Math.floor(best.size * 8 / 1000 / duration) : 0 + const coverURL = normalizeMiguImageURL(firstNonEmpty( + pickMiguImage(item.imgItems), pickMiguImage(item.albumImgs), item.img1, item.img2, item.img3, + )) + // albums 数组是搜索响应的专辑来源(顶层 albumId 常为空),与上游 Extra 的 album_id 同义 + const albumId = firstNonEmpty(item.albumId, item.albums?.[0]?.id) + const linkID = firstNonEmpty(item.contentId, item.copyrightId) + + const extra: Record = { + content_id: String(item.contentId ?? ''), + resource_type: String(bestFormat.resourceType ?? ''), + format_type: String(bestFormat.formatType ?? ''), + } + if (item.copyrightId) extra.copyright_id = item.copyrightId + if (item.songId) extra.song_id = item.songId + if (albumId) extra.album_id = albumId + + const isVIP = item.chargeAuditions === '1' || best.vip + const resourceType = String(bestFormat.resourceType ?? '2') + const id = `${item.contentId ?? ''}|${resourceType}|${String(bestFormat.formatType ?? '')}` + + // 各档位可用性(供前端展示);实际可取档位由取流接口的探测结果决定 + const types: SongType[] = candidates.map(c => ({ + type: (TONE_QUALITY[c.tone] ?? '128k') as SongType['type'], + size: String(c.size), + })) + + return { + songmid: id, + name: songName, + singer: artistNames.join(' / '), + albumId, + albumName, + interval: duration ? formatSeconds(duration) : '0', + img: coverURL || null, + lrc: null, + types, + _types: {}, + payPlay: isVIP, + // 路由/取流需要 content_id|resource_type|format_type 三元组与版权号,放在 extra 里 + extra: { ...extra, link: miguSongLink(linkID) }, + source: 'migu', + } +} + +export const formatSeconds = (sec: number): string => { + const m = Math.floor(sec / 60) + const s = Math.floor(sec % 60) + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` +} + +// ---------------- 歌曲详情 / 歌词 ---------------- + +export const fetchSongDetail = async(contentID: string): Promise => { + const url = buildUrl('http://c.musicapp.migu.cn/MIGUM2.0/v1.0/content/resourceinfo.do', { + resourceType: '2', + resourceId: contentID, + }) + const body = await miguGet(url) + const resource: MiguSongItem[] = body?.resource ?? [] + if (!resource.length) throw new Error('migu 歌曲详情为空') + return resource[0]! +} + +// ---------------- Z3D 解密(上游 DecryptAudio / miguZ3DStreamKey) ---------------- + +export const isMiguAudioMagic = (data: Buffer): boolean => { + if (!data || data.length < 4) return false + const h = data.subarray(0, 4) + return h.toString('latin1') === 'fLaC' + || (h.toString('latin1') === 'RIFF' && data.length >= 12 && data.subarray(8, 12).toString('latin1') === 'WAVE') + || h.toString('latin1') === 'OggS' + || data.subarray(0, 3).toString('latin1') === 'ID3' + || (data.length >= 12 && data.subarray(4, 8).toString('latin1') === 'ftyp') + || (data[0] === 0xFF && (data[1]! & 0xE0) === 0xE0) +} + +/** streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制 */ +export const miguZ3DStreamKey = (fileKey: string): Buffer => { + const key = String(fileKey ?? '').trim() + if (!key) throw new Error('empty z3d file key') + const iv = Buffer.from([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]) + const decipher = crypto.createDecipheriv('aes-128-cbc', Buffer.from(ANDROID_APP_KEY), iv) + // Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,Node 默认会去填充导致 bad decrypt + decipher.setAutoPadding(false) + const appCode = Buffer.concat([decipher.update(ENCRYPTED_APP_CODE), decipher.final()]) + const hash = crypto.createHash('md5').update(appCode).update(key).digest('hex').toUpperCase() + return Buffer.from(hash, 'latin1') +} + +/** Z3D 流解密:逐字节 cipher[i] - streamKey[i % 32];已是明文则原样返回 */ +export const decryptZ3D = (fileData: Buffer, fileKey: string): Buffer => { + if (!fileData?.length) throw new Error('empty z3d data') + if (isMiguAudioMagic(fileData)) return fileData + let payload = fileData + if (payload.subarray(0, 3).toString('latin1') === 'Z3D') payload = payload.subarray(3) + if (!payload.length) throw new Error('empty z3d payload') + const streamKey = miguZ3DStreamKey(fileKey) + const plain = Buffer.alloc(payload.length) + for (let i = 0; i < payload.length; i++) plain[i] = payload[i]! - streamKey[i & 31]! + if (!isMiguAudioMagic(plain)) throw new Error('z3d 解密后不是有效音频') + return plain +} diff --git a/sdk/qian/album.ts b/sdk/qian/album.ts new file mode 100644 index 0000000..be5ae70 --- /dev/null +++ b/sdk/qian/album.ts @@ -0,0 +1,180 @@ +// 千千音乐专辑 +// 搜索:/v1/search?type=3(特殊字符触发 errno 23001 时用清洗后的关键词重试,见 searchAlbumItems) +// 详情:/v1/album/info?albumAssetCode=P...(P 码可直接用;纯数字旧 id 走 /v1/album/albumid2psid 转换) +// 专辑链接:https://music.91q.com/album/ +import { APP_ID, apiGet, apiOk, joinArtists, normalizeAlbumAssetCode, mapTrack, releaseDate10, sanitizeAlbumKeyword, albumLink } from './util.ts' + +export interface QianAlbumSearchItem { + albumAssetCode?: string + title?: string + pic?: string + introduce?: string + releaseDate?: string + genre?: string + lang?: string + artist?: any[] + trackList?: any[] +} + +const mapAlbumSummary = (item: QianAlbumSearchItem) => { + const id = normalizeAlbumAssetCode(item?.albumAssetCode) + return { + id, + name: String(item?.title ?? '').trim(), + cover: String(item?.pic ?? '').trim(), + trackCount: Array.isArray(item?.trackList) ? item.trackList.length : 0, + creator: joinArtists(item?.artist), + description: String(item?.introduce ?? '').trim(), + releaseDate: releaseDate10(item?.releaseDate), + genre: String(item?.genre ?? '').trim(), + lang: String(item?.lang ?? '').trim(), + link: id ? albumLink(id) : '', + source: 'qian' as const, + } +} + +/** 单次专辑搜索;23001(词非法)时抛出带 retryWithSanitized 标记的错误 */ +const searchAlbumOnce = async(word: string, page = 1, limit = 10) => { + const body = await apiGet('/v1/search', { + word, + type: '3', + pageNo: String(Math.max(1, page)), + pageSize: String(Math.max(1, limit)), + appid: APP_ID, + }) + if (!apiOk(body)) { + const err: any = new Error(body?.errmsg || `千千专辑搜索失败(errno ${body?.errno})`) + err.retryWithSanitized = Number(body?.errno) === 23001 + throw err + } + const list = body?.data?.typeAlbum + return Array.isArray(list) ? (list as QianAlbumSearchItem[]) : [] +} + +/** 上游 searchAlbumItems:原词失败(23001)时用 sanitizeQianqianAlbumKeyword 清洗后重试一次 */ +export const searchAlbumItems = async(keyword: string, page = 1, limit = 10): Promise => { + const words = [String(keyword ?? '').trim()] + const sanitized = sanitizeAlbumKeyword(keyword) + if (sanitized && sanitized !== words[0]) words.push(sanitized) + + let lastErr: Error | null = null + for (const word of words) { + try { + return await searchAlbumOnce(word, page, limit) + } catch (err: any) { + lastErr = err + // 仅 23001 允许换清洗后的词重试;其余错误(网络/服务端)直接上抛 + if (!err?.retryWithSanitized) break + } + } + if (lastErr) throw lastErr + return [] +} + +/** 搜索专辑(返回歌单/专辑统一摘要形状) */ +export const searchAlbum = async(keyword: string, page = 1, limit = 10) => { + const word = String(keyword ?? '').trim() + const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit } + if (!word) return empty + + const items = await searchAlbumItems(word, page, limit) + const list = items + .map(mapAlbumSummary) + .filter((album) => album.id) + .slice(0, limit) + return { source: 'qian' as const, list, total: items.length, page, limit } +} + +/** 旧数字专辑 id → PSID(P 码);已是 P 码则原样返回 */ +export const resolveAlbumAssetCode = async(id: string): Promise => { + const raw = String(id ?? '').trim() + if (!raw) throw new Error('千千专辑 id 为空') + + const normalized = normalizeAlbumAssetCode(raw) + if (normalized) return normalized + + const body = await apiGet('/v1/album/albumid2psid', { albumid: raw, appid: APP_ID }) + if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑 id 转换失败(errno ${body?.errno})`) + for (const item of Array.isArray(body?.data) ? body.data : []) { + const psid = normalizeAlbumAssetCode(item?.psid) + if (psid) return psid + } + throw new Error('千千专辑 asset code 未找到') +} + +const fetchAlbumInfo = async(id: string) => { + const albumId = await resolveAlbumAssetCode(id) + const body = await apiGet('/v1/album/info', { albumAssetCode: albumId, appid: APP_ID }) + if (!apiOk(body)) throw new Error(body?.errmsg || `千千专辑详情获取失败(errno ${body?.errno})`) + const data = body?.data ?? {} + if (!normalizeAlbumAssetCode(data?.albumAssetCode)) throw new Error('千千专辑不存在或已下架') + return { albumId, data } +} + +/** 专辑详情(MusicServer 统一形状;album/info 一次返回全部曲目,这里按 page/limit 切片) */ +export const getAlbumDetail = async(id: string, page = 1, limit = 30) => { + const { albumId, data } = await fetchAlbumInfo(id) + const title = String(data?.title ?? '').trim() + const cover = String(data?.pic ?? '').trim() + const creator = joinArtists(data?.artist) + const all = Array.isArray(data?.trackList) ? data.trackList : [] + + const list = all + .map((item: any) => { + const song = mapTrack(item, { albumId, albumName: title, img: cover, singer: creator }) + return { + ...song, + // 专辑内曲目补上时长/序号(上游 Extra.track / sort) + track: Number(item?.sort) > 0 ? Number(item.sort) : undefined, + } + }) + .filter((song: any) => song.songmid) + + const start = (Math.max(1, page) - 1) * Math.max(1, limit) + const paged = list.slice(start, start + Math.max(1, limit)) + + const albumInfo = { + id: albumId, + name: title, + img: cover, + desc: String(data?.introduce ?? '').trim(), + author: creator, + releaseDate: releaseDate10(data?.releaseDate), + genre: String(data?.genre ?? '').trim(), + lang: String(data?.lang ?? '').trim(), + total: list.length, + } + + return { + source: 'qian' as const, + info: albumInfo, + list: paged, + page, + limit, + total: list.length, + allPage: Math.ceil(list.length / Math.max(1, limit)) || 1, + } +} + +/** 上游 GetAlbumSongs:只取曲目列表 */ +export const getAlbumSongs = async(id: string) => { + const { list } = await getAlbumDetail(id, 1, 10000) + return list +} + +/** 上游 ParseAlbum:支持 P 码链接 / albumAssetCode= / albumid= 三种形态 */ +export const parseAlbumLink = async(link: string) => { + const raw = String(link ?? '').trim() + const patterns = [ + /music\.91q\.com\/album\/([A-Za-z0-9]+)/, + /albumAssetCode=([A-Za-z0-9]+)/, + /albumid=(\d+)/, + ] + for (const pattern of patterns) { + const match = pattern.exec(raw) + if (match) return getAlbumDetail(match[1]) + } + throw new Error('无效的千千专辑链接') +} + +export default { searchAlbum, searchAlbumItems, getAlbumDetail, getAlbumSongs, parseAlbumLink, resolveAlbumAssetCode } diff --git a/sdk/qian/index.ts b/sdk/qian/index.ts new file mode 100644 index 0000000..c917b85 --- /dev/null +++ b/sdk/qian/index.ts @@ -0,0 +1,86 @@ +// 千千音乐(music.91q.com)平台模块 +// 上游 music-lib/qianqian 的 Node/TS 移植。匿名可用面(2026-10-02 实测): +// 搜索(type=1)/ 专辑搜索+详情(type=3、album/info)/ 歌单详情+分类+分类歌单 / +// 歌词(song/info 返回 txt URL)/ 免费曲目 128k/320k/flac 明文直链(tracklink 签名通道)。 +// 限制: +// - VIP/版权曲目 tracklink 无 path(需登录态会员 cookie),搜索保留但标记 payPlay; +// - 歌单关键词搜索(/v1/search?type=6)服务端已下线(errno 23001),searchPlaylist 返回空列表; +// - 上游 GetUserPlaylists 明确 Unsupported,本移植不提供 userPlaylist。 +// 凭据:上游 New(cookie) 的 cookie 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选)。 +import qianApi from '../apis/qian.ts' +import musicSearch from './musicSearch.ts' +import lyric from './lyric.ts' +import { getSongInfo } from './musicInfo.ts' +import album from './album.ts' +import playlist from './playlist.ts' +import parse from './parse.ts' +import { getQianCookie } from './util.ts' + +const qian = { + musicSearch, + + getMusicUrl(songInfo, type) { + return qianApi.getMusicUrl(songInfo, type) + }, + + getLyric(songInfo) { + return lyric.getLyric(songInfo) + }, + + // 千千无独立封面接口;搜索结果/专辑/歌单均自带 pic,缺失时回退 song/info + async getPic(songInfo) { + if (songInfo?.img) return songInfo.img + try { + const info = await getSongInfo(String(songInfo?.songmid ?? '')) + return info?.img || null + } catch { + return null + } + }, + + getMusicInfo(songInfo) { + return getSongInfo(String(songInfo?.songmid ?? '')) + }, + + getMusicDetailPageUrl(songInfo) { + const tsid = String(songInfo?.songmid ?? '').trim() + return tsid ? `https://music.91q.com/song/${tsid}` : 'https://music.91q.com/' + }, + + album: { + getAlbumDetail: album.getAlbumDetail, + searchAlbum: album.searchAlbum, + parseAlbumLink: album.parseAlbumLink, + }, + + // MusicServer 的 songList 契约:getList(sortId, tagId, page) / getTags() + // 千千无“榜单”概念,这里以歌单分类作为标签体系(tagId 为 /v1/tracklist/category 的 subCate id) + songList: { + getList(sortId, tagId, page = 1) { + const cate = String(tagId || sortId || '').trim() + return playlist.getCategoryPlaylists(/^\d+$/.test(cate) ? cate : '', page, 30) + }, + getTags() { + return playlist.getCategories() + }, + }, + + playlist: { + searchPlaylist: playlist.searchPlaylist, + getPlaylistDetail: playlist.getPlaylistDetail, + getPlaylistSongs: playlist.getPlaylistSongs, + getCategories: playlist.getCategories, + getCategoryPlaylists: playlist.getCategoryPlaylists, + parsePlaylistLink: playlist.parsePlaylistLink, + }, + + parse, +} + +/** 凭据存在性(cookie 仅用于 VIP/登录态资源,匿名不影响搜索与免费取流) */ +export const status = () => { + const cookie = getQianCookie() + return cookie ? { logged: true } : { logged: false } +} + +export default qian diff --git a/sdk/qian/lyric.ts b/sdk/qian/lyric.ts new file mode 100644 index 0000000..28b9373 --- /dev/null +++ b/sdk/qian/lyric.ts @@ -0,0 +1,34 @@ +// 千千音乐歌词 +// 链路:/v1/song/info → data[0].lyric 为歌词文本 URL(static-qianqian.taihe.com/*.txt)→ 直接下载文本。 +// 返回 { lyric, tlyric: '' }(千千只有原文歌词,无翻译)。 +import { httpFetch } from '../request.ts' +import { getSongInfo } from './musicInfo.ts' +import { requestHeaders } from './util.ts' + +export const getLyric = (songInfo: any) => { + const requestObj: any = { promise: null, cancelHttp() {} } + const tsid = String(songInfo?.songmid ?? songInfo?.TSID ?? '').trim() + let lyricReq: any = null + + requestObj.cancelHttp = () => { + lyricReq?.cancelHttp?.() + lyricReq = null + } + + requestObj.promise = (async() => { + if (!tsid) throw new Error('千千歌词需要 songmid(TSID)') + const info = await getSongInfo(tsid) + const lyricUrl = info?.lyricUrl + if (!lyricUrl) throw new Error('千千该歌曲暂无歌词') + + lyricReq = httpFetch(lyricUrl, { headers: requestHeaders(), timeout: 15000 }) + const { statusCode, body, raw } = await lyricReq.promise as any + if (statusCode != 200) throw new Error(`千千歌词下载失败(HTTP ${statusCode})`) + const lyric = typeof body === 'string' ? body : raw?.toString('utf8') ?? '' + return { lyric, tlyric: '' } + })() + + return requestObj +} + +export default { getLyric } diff --git a/sdk/qian/musicInfo.ts b/sdk/qian/musicInfo.ts new file mode 100644 index 0000000..62ca383 --- /dev/null +++ b/sdk/qian/musicInfo.ts @@ -0,0 +1,26 @@ +// 千千音乐单曲信息(/v1/song/info) +// 接口返回 data[]:title/albumTitle/albumAssetCode/pic/duration/artist/lyric(歌词文本 URL) +import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts' + +/** 由 TSID 取单曲信息(归一为 Song 形状,并附 lyricUrl) */ +export const getSongInfo = async(tsid: string) => { + const id = String(tsid ?? '').trim() + if (!id) throw new Error('千千歌曲 id(TSID)为空') + const body = await apiGet('/v1/song/info', { TSID: id, appid: APP_ID }) + if (!apiOk(body) && !Array.isArray(body?.data)) { + throw new Error(body?.errmsg || `千千歌曲信息获取失败(errno ${body?.errno})`) + } + const item = Array.isArray(body?.data) ? body.data[0] : null + if (!item) throw new Error('千千歌曲不存在或已下架') + + const song = mapTrack(item) + return { + ...song, + // 以入参 id 兜底(song/info 的 TSID 字段缺失时仍可继续取流/歌词) + songmid: song.songmid || id, + lyricUrl: String(item?.lyric ?? '').trim(), + raw: item, + } +} + +export default { getSongInfo } diff --git a/sdk/qian/musicSearch.ts b/sdk/qian/musicSearch.ts new file mode 100644 index 0000000..1d94057 --- /dev/null +++ b/sdk/qian/musicSearch.ts @@ -0,0 +1,35 @@ +// 千千音乐搜索(/v1/search?word=&type=1&pageNo=&pageSize=,appid + 签名) +// 响应 data.typeTrack[]:TSID/title/albumTitle/albumAssetCode/pic/duration/artist/rateFileInfo/isVip +// 注:上游会跳过 isVip != 0 的曲目(VIP 无法匿名取流);本实现保留并标记 payPlay, +// 以便聚合搜索/顺播时仍能展示(取流失败会返回明确错误,路由可走兜底链路)。 +import { APP_ID, apiGet, apiOk, mapTrack } from './util.ts' + +export const search = async(keyword: string, page = 1, limit = 20) => { + const word = String(keyword ?? '').trim() + const empty = { list: [] as any[], allPage: 1, total: 0, limit, source: 'qian' as const } + if (!word) return empty + + const body = await apiGet('/v1/search', { + word, + type: '1', + pageNo: String(page), + pageSize: String(limit), + appid: APP_ID, + }) + if (!apiOk(body) && Number(body?.errno) !== 23001) { + throw new Error(body?.errmsg || `千千搜索失败(errno ${body?.errno})`) + } + const tracks = Array.isArray(body?.data?.typeTrack) ? body.data.typeTrack : [] + const total = Number(body?.data?.total) || tracks.length + const haveMore = Number(body?.data?.haveMore) === 1 + + return { + list: tracks.map((item: any) => mapTrack(item)).filter((song: any) => song.songmid), + allPage: haveMore ? page + 1 : Math.max(page, Math.ceil(total / Math.max(1, limit)) || 1), + total, + limit, + source: 'qian' as const, + } +} + +export default { search } diff --git a/sdk/qian/parse.ts b/sdk/qian/parse.ts new file mode 100644 index 0000000..d385111 --- /dev/null +++ b/sdk/qian/parse.ts @@ -0,0 +1,35 @@ +// 千千音乐链接解析 +// 歌曲:music.91q.com/song/(上游 Parse:取 song/info 后顺带取直链,失败不影响返回) +// 专辑:music.91q.com/album/ / ?albumAssetCode= / ?albumid= +// 歌单:music.91q.com/songlist|tracklist|playlist/ / ?*id= / 裸歌单 id +import { getDownloadUrl } from '../apis/qian.ts' +import { getSongInfo } from './musicInfo.ts' +import { parseAlbumLink } from './album.ts' +import { parsePlaylistLink } from './playlist.ts' +import { firstNonEmpty } from './util.ts' + +const TSID_RE = /music\.91q\.com\/song\/(\w+)/ + +/** 上游 Parse:歌曲链接 → Song(尽力附带直链 url/type) */ +export const parseSong = async(link: string) => { + const match = TSID_RE.exec(String(link ?? '')) + if (!match) throw new Error('无效的千千歌曲链接') + const tsid = match[1] + + const song: any = await getSongInfo(tsid) + try { + const hit = await getDownloadUrl(tsid) + song.url = hit.url + song.type = hit.type + } catch { + // 与上游一致:直链获取失败忽略(VIP/下架曲目仍返回元数据) + } + delete song.lyricUrl + delete song.raw + return { ...song, songmid: firstNonEmpty(song.songmid, tsid), source: 'qian' as const } +} + +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/qian/playlist.ts b/sdk/qian/playlist.ts new file mode 100644 index 0000000..d24ad9f --- /dev/null +++ b/sdk/qian/playlist.ts @@ -0,0 +1,158 @@ +// 千千音乐歌单 +// 详情:/v1/tracklist/info?id=&type=0&pageNo=&pageSize=(支持分页,默认 pageSize=20) +// 分类:/v1/tracklist/category(group.categoryName + subCate[]) +// 分类歌单:/v1/tracklist/list?subCateId=&pageNo=&pageSize=(不传 subCateId 为全部) +// 搜索:上游用 /v1/search?type=6,但 2026-10 实测服务端已下线该搜索类型 +// (errno 23001 "不被支持的搜索类型(10000)"),官网搜索页也只有 单曲/歌手/专辑 三个 tab; +// 上游在 !state 时静默返回空列表,本实现保持一致并保留 typeSonglist 解析以备恢复。 +import { APP_ID, apiGet, apiOk, firstNonEmpty, mapTrack, playlistLink } from './util.ts' + +const mapPlaylistSummary = (item: any) => { + const id = item?.id == null ? '' : String(item.id) + const rawDesc = String(item?.desc ?? '').trim() + const tagList = Array.isArray(item?.tagList) ? item.tagList : [] + return { + id, + name: String(item?.title ?? '').trim(), + cover: String(item?.pic ?? '').trim(), + trackCount: Number(item?.trackCount ?? 0) || 0, + creator: firstNonEmpty(item?.creator, item?.author, item?.userName, item?.nickName, item?.ownerName), + description: rawDesc || tagList.join('、'), + link: id ? playlistLink(id) : '', + source: 'qian' as const, + } +} + +/** 上游 SearchPlaylist(type=6 歌单搜索;服务端已停用,返回空列表) */ +export const searchPlaylist = async(keyword: string, page = 1, limit = 10) => { + const word = String(keyword ?? '').trim() + const empty = { source: 'qian' as const, list: [] as any[], total: 0, page, limit } + if (!word) return empty + + const body = await apiGet('/v1/search', { + word, + type: '6', + pageNo: String(page), + pageSize: String(limit), + appid: APP_ID, + }) + if (!body?.state) return empty + + const items = Array.isArray(body?.data?.typeSonglist) ? body.data.typeSonglist : [] + const list = items.map(mapPlaylistSummary).filter((item: any) => item.id) + return { source: 'qian' as const, list, total: Number(body?.data?.total) || list.length, page, limit } +} + +/** 歌单详情(含曲目;tracklist/info 支持 pageNo/pageSize) */ +export const getPlaylistDetail = async(id: string, page = 1, limit = 50) => { + const playlistId = String(id ?? '').trim() + if (!playlistId) throw new Error('千千歌单 id 为空') + + const body = await apiGet('/v1/tracklist/info', { + id: playlistId, + type: '0', + pageNo: String(Math.max(1, page)), + pageSize: String(Math.max(1, limit)), + appid: APP_ID, + }) + if (!apiOk(body)) throw new Error(body?.errmsg || `千千歌单获取失败(errno ${body?.errno})`) + + const data = body?.data ?? {} + const rawTracks = Array.isArray(data?.trackList) ? data.trackList : [] + const list = rawTracks.map((item: any) => mapTrack(item)).filter((song: any) => song.songmid) + + const title = firstNonEmpty(data?.title, playlistId) + const tagList = Array.isArray(data?.tagList) ? data.tagList : [] + const cover = firstNonEmpty(data?.pic, list[0]?.img) + const creator = firstNonEmpty(data?.creator, data?.author, data?.userName, data?.nickName, data?.ownerName) + const description = firstNonEmpty(data?.desc, data?.description) || tagList.join('、') + const total = Number(data?.trackCount) || list.length + + return { + source: 'qian' as const, + info: { id: playlistId, name: title, img: cover, desc: description, author: creator, link: playlistLink(playlistId) }, + list, + page, + limit, + total, + allPage: Math.ceil(total / Math.max(1, limit)) || 1, + } +} + +/** 上游 GetPlaylistSongs:只取曲目列表 */ +export const getPlaylistSongs = async(id: string) => { + const { list } = await getPlaylistDetail(id, 1, 1000) + return list +} + +/** + * 上游 GetPlaylistCategories + * 返回 { source, list: [{ id, name, group, count }] },首项为 "全部"(id='')。 + */ +export const getCategories = async() => { + const body = await apiGet('/v1/tracklist/category', { appid: APP_ID }) + if (!apiOk(body)) throw new Error(body?.errmsg || `千千歌单分类获取失败(errno ${body?.errno})`) + + const list: Array<{ id: string; name: string; group: string; count?: number }> = [ + { id: '', name: '全部', group: '全部' }, + ] + for (const group of Array.isArray(body?.data) ? body.data : []) { + const groupName = String(group?.categoryName ?? '').trim() + for (const item of Array.isArray(group?.subCate) ? group.subCate : []) { + const id = String(item?.id ?? '').trim() + const name = String(item?.categoryName ?? '').trim() + if (!id || !name) continue + list.push({ id, name, group: groupName, count: Number(item?.count ?? 0) || 0 }) + } + } + return { source: 'qian' as const, list } +} + +/** 上游 GetCategoryPlaylists(page >= 1,limit 1..100) */ +export const getCategoryPlaylists = async(categoryId: string | number, page = 1, limit = 30) => { + const cateId = String(categoryId ?? '').trim() + const pageNo = Math.max(1, Number(page) || 1) + const pageSize = Math.min(100, Math.max(1, Number(limit) || 30)) + + const params: Record = { appid: APP_ID, pageNo: String(pageNo), pageSize: String(pageSize) } + if (cateId) params.subCateId = cateId + + const body = await apiGet('/v1/tracklist/list', params) + if (!apiOk(body)) throw new Error(body?.errmsg || `千千分类歌单获取失败(errno ${body?.errno})`) + + const result = Array.isArray(body?.data?.result) ? body.data.result : [] + const list = result + .map((item: any) => ({ ...mapPlaylistSummary(item), categoryId: cateId })) + .filter((item: any) => item.id) + return { + source: 'qian' as const, + list, + total: Number(body?.data?.total) || list.length, + page: pageNo, + limit: pageSize, + } +} + +/** 上游 ParsePlaylist:songlist/tracklist/playlist 链接、*id= 参数、或裸歌单 id */ +export const parsePlaylistLink = async(link: string) => { + const raw = String(link ?? '').trim() + const patterns = [ + /music\.91q\.com\/(?:songlist|tracklist|playlist)\/([A-Za-z0-9]+)/, + /(?:songlistid|tracklistid|playlistid|id)=([A-Za-z0-9]+)/, + ] + for (const pattern of patterns) { + const match = pattern.exec(raw) + if (match) return getPlaylistDetail(match[1]) + } + if (raw && !raw.includes('/')) return getPlaylistDetail(raw) + throw new Error('无效的千千歌单链接') +} + +export default { + searchPlaylist, + getPlaylistDetail, + getPlaylistSongs, + getCategories, + getCategoryPlaylists, + parsePlaylistLink, +} diff --git a/sdk/qian/util.ts b/sdk/qian/util.ts new file mode 100644 index 0000000..d9e5b2a --- /dev/null +++ b/sdk/qian/util.ts @@ -0,0 +1,222 @@ +// 千千音乐(music.91q.com / 91q.com)公共请求层 +// 上游 music-lib/qianqian 的全部接口都走 /v1/*,参数 = 业务参数 + appid + timestamp + sign: +// sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret) +// 该平台匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 仅影响 VIP、收藏等登录态资源, +// MusicServer 从 sdk/auth/store.ts 的 getCredentials('qian') 读取(可选,未登录留空)。 +import crypto from 'node:crypto' +import { httpFetch } from '../request.ts' +import { getCredentials } from '../auth/store.ts' + +export const APP_ID = '16073360' +export const SECRET = '0b50b02fd0d73a9c4c8c3a781c30845f' +export const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36' +export const REFERER = 'https://music.91q.com/player' +export const API_BASE = 'https://music.91q.com' + +/** 上游音质档位顺序(3000 = flac,其余为 kbps) */ +export const RATE_ORDER = ['3000', '320', '128', '64'] + +/** 千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致) */ +export const getQianCookie = (): string => { + const cred = getCredentials('qian') as { cookie?: string } | null + return cred?.cookie ? String(cred.cookie) : '' +} + +/** 上游 signParams:写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5 */ +export const signParams = (params: Record): Record => { + const signed: Record = { ...params, timestamp: String(Math.floor(Date.now() / 1000)) } + const keys = Object.keys(signed).sort() + const payload = keys.map((key) => `${key}=${signed[key]}`).join('&') + SECRET + signed.sign = crypto.createHash('md5').update(payload, 'utf8').digest('hex') + return signed +} + +export const signedUrl = (path: string, params: Record): string => + `${API_BASE}${path}?${new URLSearchParams(signParams(params) as any).toString()}` + +export const requestHeaders = (): Record => { + const cookie = getQianCookie() + return { + 'User-Agent': USER_AGENT, + Referer: REFERER, + Accept: 'application/json, text/plain, */*', + ...(cookie ? { Cookie: cookie } : {}), + } +} + +/** 发起带签名的 GET,返回可取消的请求对象;promise resolve 为响应 JSON body */ +export const apiRequest = (path: string, params: Record) => { + const req = httpFetch(signedUrl(path, params), { headers: requestHeaders(), timeout: 15000 }) + req.promise = req.promise.then(({ statusCode, body }: any) => { + if (statusCode != 200) throw new Error(`千千音乐接口失败(HTTP ${statusCode})`) + return body ?? {} + }) + return req +} + +/** 发起带签名的 GET,promise resolve 为响应 JSON body */ +export const apiGet = (path: string, params: Record): Promise => + apiRequest(path, params).promise as Promise + +/** 上游状态判定:state=true 或 errno=22000 视为成功 */ +export const apiOk = (body: any): boolean => body?.state === true || Number(body?.errno) === 22000 + +/** tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path) */ +export const trackLink = (tsid: string, rate: string, register?: (req: any) => void): Promise => { + const req = apiRequest('/v1/song/tracklink', { TSID: tsid, appid: APP_ID, rate }) + register?.(req) + return req.promise as Promise +} + +/** 时间格式 mm:ss(duration 为秒;0 显示 0) */ +export const intervalOf = (duration: any): string | number => { + const seconds = Number(duration) + if (!Number.isFinite(seconds) || seconds <= 0) return 0 + const m = Math.trunc(seconds / 60) + const s = Math.trunc(seconds % 60) + return `${m < 10 ? '0' + m : m}:${s < 10 ? '0' + s : s}` +} + +/** 千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接) */ +export const joinArtists = (artists: any): string => { + if (!Array.isArray(artists) || !artists.length) return '' + const names: string[] = [] + const seen = new Set() + const add = (name: any) => { + const n = String(name ?? '').trim() + if (!n || seen.has(n)) return + seen.add(n) + names.push(n) + } + for (const item of artists) if (Number(item?.artistType) === 38) add(item?.name) + if (!names.length) for (const item of artists) add(item?.name) + return names.join('、') +} + +/** 上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P */ +export const normalizeAlbumAssetCode = (id: any): string => { + const s = String(id ?? '').trim() + if (s.length < 2) return '' + return s[0] === 'p' || s[0] === 'P' ? `P${s.slice(1)}` : '' +} + +/** 上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(服务端 23001 时重试用) */ +export const sanitizeAlbumKeyword = (keyword: any): string => { + const trimmed = String(keyword ?? '').trim() + if (!trimmed) return '' + return trimmed + .replace(/[::"'“”‘’()()[\]【】,,/\\\-.]/g, ' ') + .split(/\s+/) + .filter(Boolean) + .join(' ') +} + +export const firstNonEmpty = (...values: any[]): string => { + for (const value of values) { + const s = String(value ?? '').trim() + if (s) return s + } + return '' +} + +/** 上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD) */ +export const releaseDate10 = (raw: any): string => { + const s = String(raw ?? '').trim() + return s.length >= 10 ? s.slice(0, 10) : s +} + +/** 上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps) */ +export const rateStats = (rateFileInfo: any, duration: number): { size: number; bitrate: number } => { + for (const rate of RATE_ORDER) { + const info = rateFileInfo?.[rate] + const size = Number(info?.size ?? 0) + if (!info || !(size > 0)) continue + if (duration > 0) return { size, bitrate: Math.trunc((size * 8) / 1000 / duration) } + return { size, bitrate: rate === '3000' ? 800 : Number(rate) } + } + return { size: 0, bitrate: 0 } +} + +// 千千 rateFileInfo → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退) +const RATE_LABEL: Record = { '128': '128k', '320': '320k', '3000': 'flac' } + +export const rateTypes = (rateFileInfo: any) => { + const types: Array<{ type: '128k' | '320k' | 'flac'; size: string }> = [] + const _types: Partial> = {} + for (const rate of ['128', '320', '3000']) { + const info = rateFileInfo?.[rate] + const size = Number(info?.size ?? 0) + if (!info || !(size > 0)) continue + const label = RATE_LABEL[rate] + const sizeText = sizeMB(size) + types.push({ type: label, size: sizeText }) + _types[label] = { size: sizeText } + } + return { types, _types } +} + +const sizeMB = (size: number): string => { + if (!size) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))) + return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}` +} + +export interface MapTrackOptions { + albumId?: string + albumName?: string + img?: string | null + singer?: string +} + +/** + * 千千曲目字段 → MusicServer 统一 Song 形状 + * 搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId), + * 歌单详情用 TSID + albumTitle + pic。 + * 注:上游 Search 会跳过 isVip != 0 的曲目;MusicServer 聚合列表保留它们并标记 payPlay, + * 取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。 + */ +export const mapTrack = (item: any, opts: MapTrackOptions = {}) => { + const duration = Number(item?.duration ?? 0) + const stats = rateStats(item?.rateFileInfo, duration) + const { types, _types } = rateTypes(item?.rateFileInfo) + const albumId = firstNonEmpty(normalizeAlbumAssetCode(item?.albumAssetCode), opts.albumId) + return { + songmid: firstNonEmpty(item?.TSID, item?.assetId, item?.tsid), + name: String(item?.title ?? '').trim(), + singer: firstNonEmpty(joinArtists(item?.artist), opts.singer), + albumId, + albumName: firstNonEmpty(item?.albumTitle, opts.albumName), + interval: intervalOf(duration), + size: stats.size || 0, + bitrate: stats.bitrate || 0, + payPlay: Number(item?.isVip ?? 0) !== 0 || Number(item?.isPaid ?? 0) !== 0, + img: firstNonEmpty(item?.pic, opts.img) || null, + lrc: null, + lrcx: '', + types, + _types, + source: 'qian' as const, + } +} + +export const songLink = (tsid: string): string => `https://music.91q.com/song/${tsid}` +export const albumLink = (id: string): string => `https://music.91q.com/album/${id}` +export const playlistLink = (id: string): string => `https://music.91q.com/songlist/${id}` + +export default { + APP_ID, + SECRET, + getUserAgent: () => USER_AGENT, + getQianCookie, + signParams, + signedUrl, + apiRequest, + apiOk, + trackLink, + mapTrack, + rateTypes, + rateStats, + sanitizeAlbumKeyword, + normalizeAlbumAssetCode, +} diff --git a/sdk/types.ts b/sdk/types.ts index 5aeb601..07f5a8c 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -2,7 +2,10 @@ // 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。 /** 音乐平台标识 */ -export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' | 'xm' | 'me' | 'qt' | 'lr' +export type Source = + | 'kw' | 'kg' | 'tx' | 'wy' | 'qs' + | 'migu' | 'qian' | 'joox' | 'bili' | '5sing' | 'apple' + | 'xm' | 'me' | 'qt' | 'lr' /** 音质标识(wav 仅部分接口声明使用) */ export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav' @@ -71,6 +74,12 @@ export interface RequestObj { cancelHttp(): void } +/** 请求句柄的宽松形态:SDK 内 promise 会在 .then() 中改写为对应结果类型 */ +export interface AnyRequestObj { + promise: Promise + cancelHttp(): void +} + /** 取流结果 */ export interface MusicUrlResult { type: string @@ -136,10 +145,10 @@ export interface PlatformModule { songList?: any hotSearch?: HotSearchModule comment?: CommentModule - getMusicUrl?(songInfo: SongInfo, type: Quality): HttpRequestObject - getLyric?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject - getPic?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject | Promise - getMusicInfo?(songInfo: SongInfo): HttpRequestObject | Promise + getMusicUrl?(songInfo: SongInfo, type: Quality): AnyRequestObj | Promise + getLyric?(songInfo: SongInfo, ...rest: unknown[]): AnyRequestObj + getPic?(songInfo: SongInfo, ...rest: unknown[]): AnyRequestObj | Promise + getMusicInfo?(songInfo: SongInfo): AnyRequestObj | Promise getMusicDetailPageUrl?(songInfo: SongInfo): string [key: string]: any } diff --git a/src/openapi/components.ts b/src/openapi/components.ts index e1b590f..084eff7 100644 --- a/src/openapi/components.ts +++ b/src/openapi/components.ts @@ -272,6 +272,12 @@ export const COMPONENT_SCHEMAS = { songList: { type: 'boolean' }, singer: { type: 'boolean', description: '支持歌手接口(/singer/:source/:id)' }, album: { type: 'boolean', description: '支持专辑接口(/album/:source/:id)' }, + albumSearch: { type: 'boolean', description: '支持专辑搜索(/album/:source/search/:keyword)' }, + playlist: { type: 'boolean', description: '支持歌单详情(/playlist/:source/:id)' }, + playlistSearch: { type: 'boolean', description: '支持歌单搜索' }, + playlistCategories: { type: 'boolean', description: '支持歌单分类' }, + parse: { type: 'boolean', description: '支持链接解析(/parse/:source)' }, + userPlaylist: { type: 'boolean', description: '支持个人歌单(/user-playlist/:source)' }, }, additionalProperties: true, }, @@ -285,6 +291,87 @@ export const COMPONENT_SCHEMAS = { }, additionalProperties: true, }, + PlaylistSummary: { + type: 'object', + properties: { + id: { type: 'string', description: '歌单/专辑 id' }, + name: { type: 'string' }, + cover: { type: 'string' }, + trackCount: { type: 'integer' }, + playCount: { type: 'integer' }, + creator: { type: 'string' }, + description: { type: 'string' }, + link: { type: 'string' }, + source: { type: 'string' }, + }, + additionalProperties: true, + }, + PlaylistSearchResult: { + type: 'object', + properties: { + list: { type: 'array', items: ref('PlaylistSummary') }, + total: { type: 'integer' }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + source: { type: 'string' }, + }, + additionalProperties: true, + }, + PlaylistDetailResult: { + type: 'object', + properties: { + source: { type: 'string' }, + info: ref('AlbumInfo'), + list: { type: 'array', items: ref('SongItem') }, + page: { type: 'integer' }, + limit: { type: ['integer', 'string', 'null'] }, + total: { type: ['integer', 'string', 'null'] }, + }, + additionalProperties: true, + }, + PlaylistCategoriesResult: { + type: 'object', + properties: { + source: { type: 'string' }, + list: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + group: { type: 'string' }, + hot: { type: 'boolean' }, + }, + additionalProperties: true, + }, + }, + }, + additionalProperties: true, + }, + ParseResult: { + type: 'object', + properties: { + source: { type: 'string' }, + type: { type: 'string', description: '解析类型:song / album / playlist' }, + song: ref('SongItem'), + playlist: ref('PlaylistSummary'), + list: { type: 'array', items: ref('SongItem'), description: 'album/playlist 解析时的曲目' }, + total: { type: 'integer' }, + }, + additionalProperties: true, + }, + UserPlaylistResult: { + type: 'object', + properties: { + list: { type: 'array', items: ref('PlaylistSummary') }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + total: { type: 'integer' }, + source: { type: 'string' }, + }, + additionalProperties: true, + }, HealthResult: { type: 'object', properties: {