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:
snowy-yang committed 2026-10-03 00:26:32 +08:00
1 parent 47ce651f46
commit 33b96f92c4
54 files changed
+6161 -5

No files matched your search

+53
View File
@@ -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 }
+72
View File
@@ -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 }
+158
View File
@@ -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 }
+49
View File
@@ -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(/&amp;/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 }
+366
View File
@@ -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 }
+73
View File
@@ -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 }