feat(sdk): 新增咪咕/千千/JOOX/哔哩哔哩/5sing/Apple Music 六平台
平台注册表由 9 个扩到 15 个,同时补齐 sdk/types.ts 的类型与 OpenAPI 文档里的能力位声明。 - migu:命中 Z3D/I3D 加密候选时不返回直链,改由服务端解密代理出流, SDK 侧只负责登记票据,避免客户端拿到无法播放的密文 - apple:只提供 preview 试听片段(完整曲为 DRM),标 noAggregate 排除在跨平台精确匹配之外,防止用它顶掉其它平台的可播结果 - 其余平台按既有 PlatformModule 约定实现搜索/专辑/歌单/歌词 为什么合并成一次提交:六平台共用同一份注册表与能力位声明, 拆开会产生互相引用、单独无法通过类型检查的中间态。
This commit is contained in:
1 parent
47ce651f46
commit
33b96f92c4
54 files changed
+6161
-5
No files matched your search
@@ -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 约定 "<songId>|<songType>"(如 "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<any> {
|
||||||
|
throw new Error('user playlists not supported')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录状态(凭据存在性判断;5sing 上游无扫码/账密登录,凭据只能手工写入 data/auth.json) */
|
||||||
|
export const status = getStatus
|
||||||
|
|
||||||
|
export default fivesing
|
||||||
@@ -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 = "<songid>|<songtype>",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<string> => {
|
||||||
|
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 }
|
||||||
@@ -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<AudioHit> => {
|
||||||
|
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<any> => {
|
||||||
|
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<string> => {
|
||||||
|
const data = await fetchNewGet(songID, songType, register)
|
||||||
|
return String(data?.dynamicWords ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上游 fetchSongInfo:音频直链 + newget 元数据 → 统一 Song 形状。
|
||||||
|
* 取流失败即抛错(与上游 Parse 链路一致);newget 失败时保留兜底名 5sing_<type>_<id>。
|
||||||
|
*/
|
||||||
|
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)
|
||||||
@@ -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 约定仍为 "<songId>|<typeEname>")。
|
||||||
|
// 上游 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 }
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// 5sing 链接解析(上游 fivesing/song.go 的 Parse + playlist.go 的 ParsePlaylist 移植)
|
||||||
|
// 歌曲: 5sing.kugou.com/(yc|fc|bz)/<数字id>.html → fetchSongInfo(直链 + newget 元数据,与上游一致:无直链即报错)
|
||||||
|
// 歌单: /{userId}/dj/<id>.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 }
|
||||||
@@ -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 解析 <li class="p_rel"> 块(上游 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 提取歌曲。
|
||||||
|
* 块正则 `<li class="p_rel">([\s\S]*?)</li>`;块内 href 提取 yc/fc/bz 与 id,s_soner 提取歌手(上游的拼写错误 soner 保留)。
|
||||||
|
*/
|
||||||
|
export const parseSongsFromHTML = (htmlContent: string) => {
|
||||||
|
const blocks = [...String(htmlContent ?? '').matchAll(/<li class="p_rel">([\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<string>()
|
||||||
|
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<string> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
yc: '原创',
|
||||||
|
fc: '翻唱',
|
||||||
|
bz: '伴奏',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游 getFirstValid:返回第一个非空字符串(空串视为无效) */
|
||||||
|
export const getFirstValid = (...urls: Array<string | null | undefined>): string => {
|
||||||
|
for (const u of urls) {
|
||||||
|
if (u) return String(u)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游 removeEmTags:去掉搜索结果里 <em class="keyword"> 高亮标签 */
|
||||||
|
export const removeEmTags = (s: any): string =>
|
||||||
|
String(s ?? '')
|
||||||
|
.replaceAll('<em class="keyword">', '')
|
||||||
|
.replaceAll('</em>', '')
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const HTML_ENTITIES: Record<string, string> = {
|
||||||
|
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<string, any>, timeout?: number, register?: (request: any) => void } = {},
|
||||||
|
): Promise<any> => {
|
||||||
|
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<string, any>, timeout?: number, register?: (request: any) => void } = {},
|
||||||
|
): Promise<any> => {
|
||||||
|
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` : ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析歌曲的 <songid>|<songtype> 约定(上游 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] ?? ''
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// 5sing 取流(上游 fivesing/download.go 的 GetDownloadURL + fivesing.go 的 fetchAudioLink 移植)
|
||||||
|
//
|
||||||
|
// 链路:GET http://mobileapi.5sing.kugou.com/song/getSongUrl?songid=<id>&songtype=<yc|fc|bz>
|
||||||
|
// → { 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 约定 "<songId>|<songType>";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 需为 "<songId>|<songType>"(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 }
|
||||||
@@ -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 }
|
||||||
@@ -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<number, string> = {
|
||||||
|
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<boolean> => {
|
||||||
|
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<string> => {
|
||||||
|
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>|<cid>`;仅给 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 需为 <bvid>|<cid> 格式')
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// JOOX URL 获取(新版 web 播放器通道 openjoox2,含 md5(secretAPIKey) 签名)
|
||||||
|
// 链路:GET cache.api.joox.com/openjoox2/v1/track/<songid>?...&secret=<md5>
|
||||||
|
// → 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 }
|
||||||
@@ -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<string, string> = {
|
||||||
|
'64k': 'LQ',
|
||||||
|
'128k': 'PQ',
|
||||||
|
'320k': 'HQ',
|
||||||
|
flac: 'SQ',
|
||||||
|
flac24bit: 'ZQ24',
|
||||||
|
wav: 'ZQ32',
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_EXT: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
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<ListenResponse> => {
|
||||||
|
const params: Record<string, string> = {
|
||||||
|
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<string, string> = {
|
||||||
|
'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<string>()
|
||||||
|
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<string>()
|
||||||
|
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<boolean> => {
|
||||||
|
try {
|
||||||
|
const cookie = getMiguCookie()
|
||||||
|
const headers: Record<string, string> = { '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<MiguUrlResult> => {
|
||||||
|
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 }
|
||||||
@@ -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<string, string[]> = {
|
||||||
|
'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 }
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Apple Music 专辑(上游 apple.go 的 SearchAlbum / ParseAlbum / GetAlbumSongs)
|
||||||
|
// 详情:amp-api /v1/catalog/<sf>/albums/<id>?extend=extendedAssetUrls
|
||||||
|
// → data[0].relationships.tracks.data[](>300 首时分页,next 由 fetchAllTrackResources 跟进)
|
||||||
|
// 链接:extractAppleID(link, 'album'),支持裸数字 id 与 /us/album/<slug>/<id> 链接。
|
||||||
|
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<any[]> => {
|
||||||
|
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 }
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// Apple Music 歌词(上游 apple.go GetLyrics)
|
||||||
|
// 通道:amp-api /v1/catalog/<sf>/songs/<id>?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<string> => {
|
||||||
|
const result = await getLyric({ songmid: songId }).promise
|
||||||
|
return result?.lyric || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getLyric }
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Apple Music 搜索(上游 apple.go 的 Search / SearchAlbum / SearchPlaylist)
|
||||||
|
// 通道:amp-api /v1/catalog/<storefront>/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 }
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Apple Music 链接解析(上游 apple.go Parse / ParseAlbum / ParsePlaylist)
|
||||||
|
// song: https://music.apple.com/us/album/<slug>/<albumId>?i=<songId>(?i= 优先)或裸数字 song id
|
||||||
|
// album: https://music.apple.com/us/album/<slug>/<id> 或裸数字 album id
|
||||||
|
// playlist: https://music.apple.com/us/playlist/<slug>/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 }
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Apple Music 歌单(上游 apple.go 的 SearchPlaylist / ParsePlaylist / GetPlaylistSongs /
|
||||||
|
// GetPlaylistCategories / GetCategoryPlaylists 与 applePlaylistToPlaylist 映射)
|
||||||
|
// 详情:amp-api /v1/catalog/<sf>/playlists/<id>?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/<slug>/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<any[]> => {
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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<string, string> => {
|
||||||
|
const result: Record<string, string> = {}
|
||||||
|
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<string> | 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<string, string> = {}, maxHops = 5): Promise<any> => {
|
||||||
|
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<string> => {
|
||||||
|
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<string> => {
|
||||||
|
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<string, string> => {
|
||||||
|
const cfg = getAppleConfig()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
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<string, any> | 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<string, any> | URLSearchParams | null | undefined,
|
||||||
|
tracker?: { current: any },
|
||||||
|
): Promise<any> => {
|
||||||
|
const url = buildAppleAmpUrl(uri, params)
|
||||||
|
const attempt = async(): Promise<any> => {
|
||||||
|
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<string, any> | null): Promise<any> => (
|
||||||
|
appleAmpGetTracked(uri, params, { current: null })
|
||||||
|
)
|
||||||
|
|
||||||
|
/** 拉取某资源 relationships.tracks 的全部分页(上游 fetchAllTrackResources;带页数上限防死循环) */
|
||||||
|
const MAX_TRACK_PAGES = 20
|
||||||
|
export const fetchAllTrackResources = async(tracks: any): Promise<any[]> => {
|
||||||
|
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 段中找 <type>/<name>/<id> 或 <type>/<id> */
|
||||||
|
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/<sf>/songs/<id>) */
|
||||||
|
export const fetchAppleSongResource = async(songId: string, include = ''): Promise<any> => {
|
||||||
|
const cfg = getAppleConfig()
|
||||||
|
const params: Record<string, string> = { 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,
|
||||||
|
}
|
||||||
@@ -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 约定 `<bvid>|<cid>`。
|
||||||
|
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
|
||||||
@@ -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<string, string> => {
|
||||||
|
const ip = randomChinaIp()
|
||||||
|
return {
|
||||||
|
'User-Agent': UA,
|
||||||
|
Referer: REFERER,
|
||||||
|
'X-Forwarded-For': ip,
|
||||||
|
'X-Real-IP': ip,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieMapFromHeaders = (headers: any): Record<string, string> => {
|
||||||
|
const raw = headers?.['set-cookie']
|
||||||
|
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
|
||||||
|
const jar: Record<string, string> = {}
|
||||||
|
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<string, string> => {
|
||||||
|
const jar: Record<string, string> = {}
|
||||||
|
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, string>): 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<number, [number, string, string]> = {
|
||||||
|
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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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<string> => {
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Bilibili 歌单(上游 bilibili/playlist.go;B站的"歌单"= 合集 ugc_season / 多分P稿件)
|
||||||
|
// SearchPlaylist: 用视频搜索 + view 判断,把「合集」和「多分P稿件」当歌单返回;
|
||||||
|
// GetPlaylistSongs / ParsePlaylist: id 约定 `season:<season_id>:<mid>:<bvid>` 或 `bvid:<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<string>()
|
||||||
|
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:<season_id>:<mid>:<bvid>` 构造歌单详情(上游 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<string, { title: string, cover: string, duration: number }> = {}
|
||||||
|
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:<season_id>:<mid>:<bvid>、bvid:<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<any[]> => {
|
||||||
|
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<any> => {
|
||||||
|
throw new Error('B站不支持歌单分类(上游未实现)')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游 GetCategoryPlaylists 返回 ErrPlaylistCategoriesUnsupported */
|
||||||
|
export const getCategoryPlaylists = async(_id: string, _page = 1, _limit = 20): Promise<any> => {
|
||||||
|
throw new Error('B站不支持歌单分类(上游未实现)')
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
searchPlaylist,
|
||||||
|
getPlaylistDetail,
|
||||||
|
getPlaylistSongs,
|
||||||
|
parsePlaylistLink,
|
||||||
|
getCategories,
|
||||||
|
getCategoryPlaylists,
|
||||||
|
}
|
||||||
@@ -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<string, string> => ({
|
||||||
|
'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<string, string> => {
|
||||||
|
const raw = headers?.['set-cookie']
|
||||||
|
const values: string[] = Array.isArray(raw) ? raw : raw ? [String(raw)] : []
|
||||||
|
const jar: Record<string, string> = {}
|
||||||
|
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<string> | null = null
|
||||||
|
|
||||||
|
/** 请求用 Cookie:登录态优先;否则懒加载一份匿名 buvid3(内存缓存,不落盘) */
|
||||||
|
export const getCookie = async(): Promise<string> => {
|
||||||
|
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(/<em class="keyword">/g, '').replace(/<\/em>/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** songmid 约定:`<bvid>|<cid>`(与上游 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<any>): Promise<any[]> => {
|
||||||
|
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<string, any>,
|
||||||
|
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<any> => {
|
||||||
|
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<any[]> => {
|
||||||
|
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<string, { title: string, cover: string, duration: number }>, title: string, cover: string }> => {
|
||||||
|
if (!mid || !seasonId) throw new Error('合集信息无效')
|
||||||
|
const index: Record<string, { title: string, cover: string, duration: number }> = {}
|
||||||
|
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<string, { title: string, cover: string, duration: number }> = {},
|
||||||
|
): 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<any[]> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -7,6 +7,12 @@ import xm from './xm/index.ts'
|
|||||||
import me from './me/index.ts'
|
import me from './me/index.ts'
|
||||||
import qt from './qt/index.ts'
|
import qt from './qt/index.ts'
|
||||||
import lr from './lr/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 kwQualitys } from './apis/kw.ts'
|
||||||
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
||||||
import { supportQualitys as txQualitys } from './apis/tx.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 meQualitys } from './apis/me.ts'
|
||||||
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
import { supportQualitys as qtQualitys } from './apis/qt.ts'
|
||||||
import { supportQualitys as lrQualitys } from './apis/lr.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 type { PlatformModule, SearchResult, SongInfo } from './types.ts'
|
||||||
import {
|
import {
|
||||||
sizeFormate,
|
sizeFormate,
|
||||||
@@ -41,6 +53,13 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
|
|||||||
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
{ id: 'me', name: '猫耳FM', module: me, noAggregate: true },
|
||||||
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
{ id: 'qt', name: '蜻蜓FM', module: qt, noAggregate: true },
|
||||||
{ id: 'lr', name: '懒人听书', module: lr, 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]))
|
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
|
||||||
@@ -62,6 +81,12 @@ export const supportQuality: Record<string, string[]> = {
|
|||||||
me: meQualitys,
|
me: meQualitys,
|
||||||
qt: qtQualitys,
|
qt: qtQualitys,
|
||||||
lr: lrQualitys,
|
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)
|
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
|
||||||
|
|||||||
@@ -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/<id>/tracks?country=hk&lang=zh_TW&index=&num=&secret=
|
||||||
|
// (web 播放器通道,需 md5 签名;实测 2026-10-02 HTTP 200)
|
||||||
|
// 兜底:www.joox.com/<region>/album/<id> 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<string>()
|
||||||
|
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 }
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// JOOX 歌词(上游 joox/lyric.go 移植)
|
||||||
|
// 端点:GET https://api.joox.com/web-fcgi-bin/web_lyric?musicid=<id>&country=sg&lang=zh_cn
|
||||||
|
// 响应形如 MusicJsonCallback({"code":0,"lyric":"<base64>"}),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<string> => {
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// JOOX 歌曲详情 / 取流通道(openjoox2 v1,web 播放器同款签名接口)
|
||||||
|
// 端点:GET https://cache.api.joox.com/openjoox2/v1/track/<id>
|
||||||
|
// ?country=hk&lang=zh_TW&lyric=1&fs=<0|1>&im=<0|1>[&uid=<wmid>][&usk=<session_key>]&secret=<md5>
|
||||||
|
// 签名: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<string, any> = {
|
||||||
|
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<any> => {
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// JOOX 搜索(上游 joox/song.go Search 移植)
|
||||||
|
// 端点:GET https://cache.api.joox.com/openjoox/v3/search?country=sg&lang=zh_cn&keyword=<kw>
|
||||||
|
// 返回 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<string>()
|
||||||
|
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 }
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// JOOX 链接解析(上游 joox/song.go Parse + album.go ParseAlbum + playlist.go ParsePlaylist)
|
||||||
|
// 歌曲:joox.com/<region>/single/<id> 或裸 ID(>10 字符且不含 '/')→ openjoox2 详情 → Song(含直链)
|
||||||
|
// 专辑:joox.com/.../album/<id>、h_activity_id=、albumid= → album.getAlbumDetail
|
||||||
|
// 歌单:joox.com/.../playlist/<id>、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 }
|
||||||
@@ -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/<id>/tracks?...&secret= ← 新版 web 播放器通道
|
||||||
|
// (上游 playlist.go 里的 openjoox/v3/playlist 已 404;网页兜底仍保留)
|
||||||
|
// 兜底:www.joox.com/{id,sg,hk}/playlist/<id> SSR __NEXT_DATA__
|
||||||
|
// props.pageProps.allPlaylistTracks / playlistDetailList(含 content.page 嵌套变体)
|
||||||
|
// 分类:www.joox.com/sg/playlist(302 → /<region> 首页)__NEXT_DATA__.props.pageProps.mlList
|
||||||
|
// 链接解析:joox.com/{region}/playlist/<id>、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<string>()
|
||||||
|
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<string>()
|
||||||
|
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,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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, any>): 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<string, any> = {}): Record<string, any> => ({
|
||||||
|
'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 = /<script[^>]*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 页面重定向次数过多')
|
||||||
|
}
|
||||||
@@ -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<string>()
|
||||||
|
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 }
|
||||||
@@ -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>(含裸 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
|
||||||
@@ -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 }
|
||||||
@@ -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<any> => {
|
||||||
|
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<typeof s> => 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 }
|
||||||
@@ -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<string>()
|
||||||
|
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 }
|
||||||
@@ -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<string, string> = {
|
||||||
|
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<string, string> = {}): Promise<any> => {
|
||||||
|
const cookie = getMiguCookie()
|
||||||
|
const headers: Record<string, string> = { '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, string | number>): 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 | undefined | null>): string => {
|
||||||
|
for (const value of values) {
|
||||||
|
if (value != null && String(value).trim() !== '') return String(value)
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const firstNonZeroString = (...values: Array<string | undefined | null>): 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<string>()
|
||||||
|
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<string>()
|
||||||
|
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<string, { size: string }>
|
||||||
|
payPlay?: boolean
|
||||||
|
/** 源特有元数据(取流/歌词需要):contentId/resourceType/formatType/copyrightId/songId/albumId */
|
||||||
|
extra: Record<string, string>
|
||||||
|
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<string, string> = {
|
||||||
|
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<MiguSongItem> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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/<albumAssetCode>
|
||||||
|
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<QianAlbumSearchItem[]> => {
|
||||||
|
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<string> => {
|
||||||
|
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 }
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// 千千音乐链接解析
|
||||||
|
// 歌曲:music.91q.com/song/<TSID>(上游 Parse:取 song/info 后顺带取直链,失败不影响返回)
|
||||||
|
// 专辑:music.91q.com/album/<P码> / ?albumAssetCode= / ?albumid=
|
||||||
|
// 歌单:music.91q.com/songlist|tracklist|playlist/<id> / ?*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 }
|
||||||
@@ -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<string, any> = { 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,
|
||||||
|
}
|
||||||
@@ -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<string, any>): Record<string, any> => {
|
||||||
|
const signed: Record<string, any> = { ...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, any>): string =>
|
||||||
|
`${API_BASE}${path}?${new URLSearchParams(signParams(params) as any).toString()}`
|
||||||
|
|
||||||
|
export const requestHeaders = (): Record<string, string> => {
|
||||||
|
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<string, any>) => {
|
||||||
|
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<string, any>): Promise<any> =>
|
||||||
|
apiRequest(path, params).promise as Promise<any>
|
||||||
|
|
||||||
|
/** 上游状态判定: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<any> => {
|
||||||
|
const req = apiRequest('/v1/song/tracklink', { TSID: tsid, appid: APP_ID, rate })
|
||||||
|
register?.(req)
|
||||||
|
return req.promise as Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时间格式 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<string>()
|
||||||
|
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<string, '128k' | '320k' | 'flac'> = { '128': '128k', '320': '320k', '3000': 'flac' }
|
||||||
|
|
||||||
|
export const rateTypes = (rateFileInfo: any) => {
|
||||||
|
const types: Array<{ type: '128k' | '320k' | 'flac'; size: string }> = []
|
||||||
|
const _types: Partial<Record<'128k' | '320k' | 'flac', { size: string }>> = {}
|
||||||
|
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,
|
||||||
|
}
|
||||||
+14
-5
@@ -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 仅部分接口声明使用) */
|
/** 音质标识(wav 仅部分接口声明使用) */
|
||||||
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
|
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
|
||||||
@@ -71,6 +74,12 @@ export interface RequestObj<T> {
|
|||||||
cancelHttp(): void
|
cancelHttp(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 请求句柄的宽松形态:SDK 内 promise 会在 .then() 中改写为对应结果类型 */
|
||||||
|
export interface AnyRequestObj {
|
||||||
|
promise: Promise<any>
|
||||||
|
cancelHttp(): void
|
||||||
|
}
|
||||||
|
|
||||||
/** 取流结果 */
|
/** 取流结果 */
|
||||||
export interface MusicUrlResult {
|
export interface MusicUrlResult {
|
||||||
type: string
|
type: string
|
||||||
@@ -136,10 +145,10 @@ export interface PlatformModule {
|
|||||||
songList?: any
|
songList?: any
|
||||||
hotSearch?: HotSearchModule
|
hotSearch?: HotSearchModule
|
||||||
comment?: CommentModule
|
comment?: CommentModule
|
||||||
getMusicUrl?(songInfo: SongInfo, type: Quality): HttpRequestObject
|
getMusicUrl?(songInfo: SongInfo, type: Quality): AnyRequestObj | Promise<any>
|
||||||
getLyric?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject
|
getLyric?(songInfo: SongInfo, ...rest: unknown[]): AnyRequestObj
|
||||||
getPic?(songInfo: SongInfo, ...rest: unknown[]): HttpRequestObject | Promise<any>
|
getPic?(songInfo: SongInfo, ...rest: unknown[]): AnyRequestObj | Promise<any>
|
||||||
getMusicInfo?(songInfo: SongInfo): HttpRequestObject | Promise<any>
|
getMusicInfo?(songInfo: SongInfo): AnyRequestObj | Promise<any>
|
||||||
getMusicDetailPageUrl?(songInfo: SongInfo): string
|
getMusicDetailPageUrl?(songInfo: SongInfo): string
|
||||||
[key: string]: any
|
[key: string]: any
|
||||||
}
|
}
|
||||||
@@ -272,6 +272,12 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
songList: { type: 'boolean' },
|
songList: { type: 'boolean' },
|
||||||
singer: { type: 'boolean', description: '支持歌手接口(/singer/:source/:id)' },
|
singer: { type: 'boolean', description: '支持歌手接口(/singer/:source/:id)' },
|
||||||
album: { type: 'boolean', description: '支持专辑接口(/album/: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,
|
additionalProperties: true,
|
||||||
},
|
},
|
||||||
@@ -285,6 +291,87 @@ export const COMPONENT_SCHEMAS = {
|
|||||||
},
|
},
|
||||||
additionalProperties: true,
|
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: {
|
HealthResult: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
Reference in new issue
Block a user