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 ✓
This commit is contained in:
snowy-yang committed 2026-10-03 21:04:55 +08:00
1 parent 8c02fb5c15
commit e1357dd412
50 files changed
+178 -178

No files matched your search

+130
View File
@@ -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: 'mg' as const,
}
}).filter(Boolean)
return { list, source: 'mg' 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: 'mg' 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 }
+63
View File
@@ -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 mgApi from '../apis/mg.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 mg = {
musicSearch,
getMusicUrl(songInfo: any, type: string) {
return mgApi.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 mg
+42
View File
@@ -0,0 +1,42 @@
// 咪咕歌词 —— resourceinfo.do 取 lrcUrl/lyricUrl 再下载文本
// 上游对应:music-lib/migu/lyric.go
import { buildUrl, fetchSongDetail, miguGet } from './util.ts'
import { httpFetch } from '../common/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 }
+44
View File
@@ -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: 'mg' as const,
}
}
export default { search }
+153
View File
@@ -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: 'mg' 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: 'mg' 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: 'mg' 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: 'mg' 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: 'mg' 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 }
+432
View File
@@ -0,0 +1,432 @@
// 咪咕音乐(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
}