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:
1 parent
8c02fb5c15
commit
e1357dd412
50 files changed
+178
-178
No files matched your search
+130
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user