Files
MusicServer/sdk/mg/util.ts
T
snowy-yang e1357dd412 refactor(api)!: 平台 id 回退——joox/5sing/bili/apple 恢复全名,目录对齐 bd/mg
- 撤销 3381e9b/1b972ec 中 jx/5s/bl/ap 四个短码,恢复 joox/5sing/bili/apple;
  qian→bd、migu→mg 保留
- 目录与文件对齐保留的新 id:sdk/qian/→sdk/bd/、sdk/migu/→sdk/mg/、
  sdk/apis/qian.ts→bd.ts、migu.ts→mg.ts、src/http/migu-stream.ts→mg-stream.ts
  (规则改为「目录=对外 id」,聚合入口的模块变量同步更名;music.migu.cn 等域名、
  上游 music-lib 对照注释、miguGet 等功能标识符不动)
- 对外 id 全表:kw kg tx wy qs xm me qt lr mg bd joox 5sing bili apple

**破坏性变更**:joox/5sing/bili/apple 四平台的 URL、/sources 返回与
data/auth.json 凭据键恢复全名,已存短码键的登录态需重新登录。

验证:typecheck ✓、pnpm test 112/112 ✓
2026-10-03 21:04:55 +08:00

433 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 咪咕音乐(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 '../common/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('mg') 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: 'mg'
[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 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: 'mg',
}
}
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
}