Compare commits
3
Commits
0f9989b26f
...
0f9cff2a08
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f9cff2a08 | ||
|
|
aa4f2eec67 | ||
|
|
ddc44a276d |
No files matched your search
+17
-7
@@ -2,6 +2,8 @@
|
|||||||
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
|
// 接口: https://gateway.kugou.com/v5/url(x-router: trackercdn.kugou.com,android 签名)
|
||||||
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
|
// 登录后自动携带 token/userid(见 /login/kg);匿名仅能获取部分免费资源,
|
||||||
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
|
// VIP/受限资源会返回 status:2。quality: 128k→128, 320k→320, flac→flac, flac24bit→high
|
||||||
|
// 成功响应(status:1)播放地址在顶层 url: string[] 与 backupUrl: string[](2026-10 实测;
|
||||||
|
// 旧 data:[{url}] 结构仅作兼容兜底)。album_audio_id 传 NaN 会被网关拒答,必须归零。
|
||||||
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
// 统一接口约定:apis('kg').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
// promise resolve → { type, url }
|
// promise resolve → { type, url }
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ const SALT_KEY = '57ae12eb6890223e355ccfcb74edf70d'
|
|||||||
const APPID = 1005
|
const APPID = 1005
|
||||||
const CLIENTVER = 11430
|
const CLIENTVER = 11430
|
||||||
|
|
||||||
|
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : 0 }
|
||||||
|
|
||||||
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
|
const qualityMap = { '128k': '128', '320k': '320', flac: 'flac', flac24bit: 'high' }
|
||||||
|
|
||||||
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
|
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
|
||||||
@@ -38,14 +42,14 @@ export const getMusicUrl = (songInfo, type) => {
|
|||||||
const params: Record<string, any> = {
|
const params: Record<string, any> = {
|
||||||
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
dfid: dev.dfid, mid: dev.mid, uuid: '-', appid: APPID, clientver: CLIENTVER,
|
||||||
clienttime: Math.floor(Date.now() / 1000),
|
clienttime: Math.floor(Date.now() / 1000),
|
||||||
album_id: Number(songInfo.albumId ?? 0),
|
album_id: num(songInfo.albumId),
|
||||||
area_code: 1,
|
area_code: 1,
|
||||||
hash,
|
hash,
|
||||||
ssa_flag: 'is_fromtrack',
|
ssa_flag: 'is_fromtrack',
|
||||||
version: CLIENTVER,
|
version: CLIENTVER,
|
||||||
page_id: 151369488,
|
page_id: 151369488,
|
||||||
quality: qualityMap[type] ?? '128',
|
quality: qualityMap[type] ?? '128',
|
||||||
album_audio_id: Number(songInfo.songmid ?? 0),
|
album_audio_id: num(songInfo.songmid),
|
||||||
behavior: 'play',
|
behavior: 'play',
|
||||||
pid: 2,
|
pid: 2,
|
||||||
cmd: 26,
|
cmd: 26,
|
||||||
@@ -75,12 +79,18 @@ export const getMusicUrl = (songInfo, type) => {
|
|||||||
}).promise
|
}).promise
|
||||||
|
|
||||||
const body = r.body && typeof r.body === 'object' ? r.body : {}
|
const body = r.body && typeof r.body === 'object' ? r.body : {}
|
||||||
if (body.status !== 1 || !Array.isArray(body.data)) {
|
if (body.status !== 1) {
|
||||||
throw new Error(body.error || (auth ? '获取失败' : '获取失败(该资源需要酷狗登录)'))
|
throw new Error(body.error || (auth ? `获取失败(status ${body.status ?? '未知'})` : '获取失败(该资源需要酷狗登录)'))
|
||||||
}
|
}
|
||||||
const item = body.data.find(d => d.url) ?? body.data[0]
|
let url = ''
|
||||||
const url = item?.url || item?.fallback
|
if (Array.isArray(body.url)) url = body.url[0] || ''
|
||||||
if (!url) throw new Error('获取失败')
|
else if (Array.isArray(body.backupUrl)) url = body.backupUrl[0] || ''
|
||||||
|
else if (Array.isArray(body.data)) {
|
||||||
|
// 旧版 data:[{url, fallback}] 结构兜底
|
||||||
|
const item = body.data.find(d => d.url) ?? body.data[0]
|
||||||
|
url = item?.url || item?.fallback || ''
|
||||||
|
}
|
||||||
|
if (!url) throw new Error('获取失败(网关未返回播放地址)')
|
||||||
return { type, url }
|
return { type, url }
|
||||||
})()
|
})()
|
||||||
return requestObj
|
return requestObj
|
||||||
|
|||||||
+130
-73
@@ -1,50 +1,126 @@
|
|||||||
// 酷我音乐 URL 获取(直连)
|
// 酷我音乐 URL 获取(直连)
|
||||||
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
// 统一接口约定:apis('kw').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
// promise resolve → { type, url }
|
// promise resolve → { type, url }(type 为实际拿到的音质,请求档位缺失时沿降级链回退)
|
||||||
//
|
//
|
||||||
// 取流方案(按序回退):
|
// 取流通道(按优先级):
|
||||||
// 1. antiserver.kuwo.cn/anti.s(convert_url3 老接口,无需 token,128k/320k 有效,
|
// 1) http://mobi.kuwo.cn/mobi.s convert_url_with_sign(主通道)
|
||||||
// 未登录时部分资源只返回试听片段)
|
// source=kwplayer_ar_8.5.5.0_apk_keluze.apk 指纹 + user=0 匿名即可。
|
||||||
// 2. www.kuwo.cn/api/v1/www/music/playUrl(需 csrf token,token 失效自动重取重试一次)
|
// 2026-10-01 实测(含整段 MPEG 帧逐帧校验,非仅看开头):
|
||||||
//
|
// - 付费/VIP 歌匿名放行真音频:晴天(rid 228908) 拿到 2000k flac 52.83MB(269.7s,fLaC
|
||||||
// 2026-09-19 实测结论(勿轻易改链):
|
// STREAMINFO 校验)与 320k mp3 10.29MB(10329 帧全程 320kbps/44100Hz 对齐),
|
||||||
// - mobi.kuwo.cn/mobi.s(DES 加密 query)已进防盗链蜜罐:
|
// 均与搜索接口 N_MINFO 元数据的官方大小逐字节吻合;
|
||||||
// 任意 rid(含不存在的)都返回 200 与 MP3 地址,但内容是《防盗链打击音频》假文件,
|
// - hi-res 也可用:起风了(rid 82239887) br=4000kflac → 88.2kHz flac 109.12MB;
|
||||||
// 且响应无法与真实响应区分,故不接入默认链路;DES 实现可参考 UnblockNeteaseMusic 上游 kwDES。
|
// - 请求档位不存在时会静默降级(2000kflac→48k aac、4000kflac→128k mp3)甚至升级到
|
||||||
// - playUrl 对新 token(Hm_Iuvt_* cookie)返回 "The request is illegal!"(要求签名),
|
// mgg/mflac 加密格式,因此必须严格校验「返回 bitrate == 请求档位 且 格式为明文音频」,
|
||||||
// 首页也不再下发 kw_token,getToken 会失败——通道名存实亡,仅作最后回退。
|
// 不匹配视为未命中、沿降级链换档重试;
|
||||||
// - flac 及以上音质各匿名通道均要求 VIP 登录态;接入酷我登录 token 后可重估 mobi.s。
|
// - 此前「mobi.s 吐 11 秒蜜罐假 MP3」的结论仅适用于其它 type/source 参数组合,
|
||||||
|
// 本组合已验证为真音频。
|
||||||
|
// 2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
|
||||||
|
// 签名:GET https://www.kuwo.cn/ 首页拿动态 cookie(Hm_Iuvt_*,名字与值均随机下发),
|
||||||
|
// 以「cookie 值为消息、cookie 名为密钥」跑异或哈希得 Secret 头(酷我 web 端算法,
|
||||||
|
// 参考 qyhqiu/kuwoMusicApi 的 secret.js)。token 会话级缓存,失效自动刷新重试一次。
|
||||||
|
// 限制:服务器无视 br 参数,匿名恒返回 128k MP3;VIP 歌明确报「该歌曲为付费内容」,
|
||||||
|
// 不吐假音频(作为 mobi 通道网络故障时的兜底)。
|
||||||
|
// - 写死 token(含旧 kw_token cookie)必报 "The request is illegal!",token 必须动态获取。
|
||||||
|
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
import { httpFetch } from '../common/request.ts'
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
|
||||||
// 音质映射:128k → 128kmp3, 320k → 320kmp3, flac → flac(antiserver/mobi.s 用 mp3/flac 格式)
|
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36'
|
||||||
const qualityMap = {
|
const MOBI_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36'
|
||||||
'128k': { br: '128kmp3', format: 'mp3' },
|
|
||||||
'320k': { br: '320kmp3', format: 'mp3' },
|
// 音质 → mobi.s 请求档位(bitrate 为响应必须精确命中的数值)
|
||||||
flac: { br: 'flac', format: 'flac' },
|
const qualityTiers: Record<string, { br: string, bitrate: number }> = {
|
||||||
flac24bit: { br: 'flac', format: 'flac' },
|
'128k': { br: '128kmp3', bitrate: 128 },
|
||||||
|
'320k': { br: '320kmp3', bitrate: 320 },
|
||||||
|
'flac': { br: '2000kflac', bitrate: 2000 },
|
||||||
|
'flac24bit': { br: '4000kflac', bitrate: 4000 },
|
||||||
}
|
}
|
||||||
|
|
||||||
// 官方直连接口支持的音质(sdk/index.ts 汇总为 supportQuality)
|
// 档位缺失时的降级链(请求档位 → 逐级尝试)
|
||||||
export const supportQualitys = Object.keys(qualityMap)
|
const fallbackChain: Record<string, string[]> = {
|
||||||
|
'flac24bit': ['flac', '320k', '128k'],
|
||||||
|
'flac': ['320k', '128k'],
|
||||||
|
'320k': ['128k'],
|
||||||
|
'128k': [],
|
||||||
|
}
|
||||||
|
|
||||||
let token = ''
|
// 明文音频格式白名单;mgg/mflac/mmp4/zp 等为加密格式,拿到也不能播
|
||||||
let tokenPromise: Promise<string> | null = null
|
const plainFormats = new Set(['mp3', 'flac', 'ogg', 'aac', 'm4a'])
|
||||||
|
|
||||||
|
// 匿名通道实际可用音质(sdk/index.ts 汇总为 supportQuality)
|
||||||
|
export const supportQualitys = ['128k', '320k', 'flac', 'flac24bit']
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 kw csrf token(访问首页拿 cookie)
|
* mobi.s convert_url_with_sign 取流
|
||||||
|
* 未命中(无 url / bitrate 不匹配 / 加密格式)resolve null,由调用方沿降级链换档
|
||||||
*/
|
*/
|
||||||
const getToken = (retryNum = 0) => {
|
const getUrlByMobi = (songmid: string, tier: { br: string, bitrate: number }): Promise<{ url: string } | null> => {
|
||||||
|
const requestObj = httpFetch(`http://mobi.kuwo.cn/mobi.s?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk&type=convert_url_with_sign&br=${tier.br}&rid=${songmid}`, {
|
||||||
|
method: 'get',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': MOBI_UA,
|
||||||
|
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
return requestObj.promise.then(({ statusCode, body }) => {
|
||||||
|
const data = body?.data
|
||||||
|
if (statusCode != 200 || !data?.url) return null
|
||||||
|
if (Number(data.bitrate) != tier.bitrate || !plainFormats.has(String(data.format))) return null
|
||||||
|
return { url: data.url }
|
||||||
|
}).catch(() => null)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KwToken { name: string, value: string, secret: string }
|
||||||
|
|
||||||
|
let token: KwToken | null = null
|
||||||
|
let tokenPromise: Promise<KwToken> | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 酷我 web 端 Secret 头哈希:t 为消息(cookie 值),e 为密钥(cookie 名)
|
||||||
|
*/
|
||||||
|
const kwSecret = (t: string, e: string): string => {
|
||||||
|
let digits = ''
|
||||||
|
for (let i = 0; i < e.length; i++) digits += e.charCodeAt(i).toString()
|
||||||
|
const r = Math.floor(digits.length / 5)
|
||||||
|
const o = parseInt(digits.charAt(r) + digits.charAt(2 * r) + digits.charAt(3 * r) + digits.charAt(4 * r) + digits.charAt(5 * r))
|
||||||
|
const l = Math.ceil(e.length / 2)
|
||||||
|
const c = 2 ** 31 - 1
|
||||||
|
if (o < 2) throw new Error('酷我 Secret 哈希失败')
|
||||||
|
let d = Math.round(1e9 * Math.random()) % 1e8
|
||||||
|
digits += d
|
||||||
|
while (digits.length > 10) digits = (parseInt(digits.substring(0, 10)) + parseInt(digits.substring(10, digits.length))).toString()
|
||||||
|
let n = (o * parseInt(digits) + l) % c
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < t.length; i++) {
|
||||||
|
const x = t.charCodeAt(i) ^ Math.floor(n / c * 255)
|
||||||
|
out += x < 16 ? `0${x.toString(16)}` : x.toString(16)
|
||||||
|
n = (o * n + l) % c
|
||||||
|
}
|
||||||
|
let hex = d.toString(16)
|
||||||
|
while (hex.length < 8) hex = `0${hex}`
|
||||||
|
return out + hex
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话 token(访问首页,解析动态下发的 Hm_Iuvt_* cookie 名与值)
|
||||||
|
*/
|
||||||
|
const getToken = (retryNum = 0): Promise<KwToken> => {
|
||||||
if (token) return Promise.resolve(token)
|
if (token) return Promise.resolve(token)
|
||||||
if (tokenPromise) return tokenPromise
|
if (tokenPromise) return tokenPromise
|
||||||
tokenPromise = httpFetch('http://www.kuwo.cn/', {
|
tokenPromise = httpFetch('https://www.kuwo.cn/', {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
|
headers: { 'User-Agent': UA },
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
}).promise.then(({ headers }) => {
|
}).promise.then(({ headers }) => {
|
||||||
tokenPromise = null
|
tokenPromise = null
|
||||||
const match = ((headers['set-cookie'] as string[]) || []).join(';').match(/kw_token=(\w+)/)
|
const cookies = ((headers['set-cookie'] as string[]) || []).map(c => {
|
||||||
if (!match) throw new Error('获取token失败')
|
const i = c.indexOf('=')
|
||||||
token = match[1]
|
return { name: c.slice(0, i).trim(), value: i >= 0 ? c.slice(i + 1).split(';')[0] : '' }
|
||||||
|
}).filter(c => /^Hm_Iuvt_\w+$/.test(c.name) && c.value)
|
||||||
|
if (!cookies.length) throw new Error('获取酷我token失败')
|
||||||
|
token = { name: cookies[0].name, value: cookies[0].value, secret: kwSecret(cookies[0].value, cookies[0].name) }
|
||||||
return token
|
return token
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
tokenPromise = null
|
tokenPromise = null
|
||||||
@@ -55,64 +131,45 @@ const getToken = (retryNum = 0) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过 antiserver 老接口获取(无需 token)
|
* 通过官方 playUrl 接口获取(需 Secret 签名;匿名恒 128k)
|
||||||
* @param {string} rid MUSIC_xxx
|
* @param t 本次请求使用的 token(显式传入,避免与并发刷新竞态)
|
||||||
* @param {string} br 128kmp3 / 320kmp3 / flac
|
|
||||||
* @param {string} format mp3 / flac
|
|
||||||
*/
|
*/
|
||||||
const getUrlByAntiServer = (rid, br, format) => {
|
const getUrlByPlayUrl = (songmid: string, t: KwToken, retryNum = 0) => {
|
||||||
return httpFetch(`http://antiserver.kuwo.cn/anti.s?type=convert_url3&rid=${rid}&format=${format}&response=url&br=${br}`, {
|
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||||
|
requestObj.promise = httpFetch(`https://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId=${randomUUID()}`, {
|
||||||
method: 'get',
|
method: 'get',
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
Cookie: `${t.name}=${t.value}`,
|
||||||
|
Secret: t.secret,
|
||||||
|
Referer: 'https://www.kuwo.cn/',
|
||||||
|
'User-Agent': UA,
|
||||||
},
|
},
|
||||||
timeout: 15000,
|
timeout: 15000,
|
||||||
}).promise.then(({ statusCode, body }) => {
|
}).promise.then(({ statusCode, body }) => {
|
||||||
if (statusCode != 200 || typeof body != 'object' || body.code != 200 || !body.url) {
|
const msg: string = body?.msg || body?.message || ''
|
||||||
return Promise.reject(new Error(body?.msg || '获取失败'))
|
if (statusCode != 200 || !body?.success || !body.data?.url) {
|
||||||
}
|
// 签名/token 失效:刷新后重试一次(VIP 拒绝等其他错误不重试)
|
||||||
return body.url
|
if (retryNum == 0 && /illegal/i.test(msg)) {
|
||||||
})
|
token = null
|
||||||
}
|
return getToken().then(fresh => getUrlByPlayUrl(songmid, fresh, ++retryNum).promise)
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过官方 playUrl 接口获取(需要 csrf token)
|
|
||||||
*/
|
|
||||||
const getUrlByPlayUrl = (songmid, br, retryNum = 0) => {
|
|
||||||
const requestObj = httpFetch(`http://www.kuwo.cn/api/v1/www/music/playUrl?mid=${songmid}&type=music&br=${br}`, {
|
|
||||||
method: 'get',
|
|
||||||
headers: {
|
|
||||||
csrf: token,
|
|
||||||
cookie: `kw_token=${token}`,
|
|
||||||
Referer: 'http://www.kuwo.cn/',
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
||||||
},
|
|
||||||
timeout: 15000,
|
|
||||||
})
|
|
||||||
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
|
|
||||||
if (statusCode != 200 || body.code != 200) {
|
|
||||||
// token 失效时重新获取再试一次
|
|
||||||
if (token && /token|403|401/.test(body?.msg || '') && retryNum == 0) {
|
|
||||||
token = ''
|
|
||||||
return getUrlByPlayUrl(songmid, br, ++retryNum).promise
|
|
||||||
}
|
}
|
||||||
return Promise.reject(new Error(body?.msg || '获取失败'))
|
return Promise.reject(new Error(msg || '获取失败'))
|
||||||
}
|
}
|
||||||
if (!body.data?.url) return Promise.reject(new Error('获取失败'))
|
// 服务器无视 br,匿名实际只回 128k,如实上报
|
||||||
return body.data.url
|
return { type: '128k', url: body.data.url }
|
||||||
})
|
})
|
||||||
return requestObj
|
return requestObj
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getMusicUrl = (songInfo, type) => {
|
export const getMusicUrl = (songInfo, type) => {
|
||||||
const requestObj = { cancelHttp() {}, promise: null as any }
|
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||||
const { br, format } = qualityMap[type] || qualityMap['128k']
|
const start = qualityTiers[type] ? type : '128k'
|
||||||
const rid = `MUSIC_${songInfo.songmid}`
|
const chain = [start, ...fallbackChain[start]]
|
||||||
|
// 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
|
||||||
requestObj.promise = getUrlByAntiServer(rid, br, format).catch(() => {
|
requestObj.promise = chain.reduce(
|
||||||
// antiserver 无此音质(flac 常被拒)时回退官方接口
|
(p, tierType) => p.then(res => res ?? getUrlByMobi(songInfo.songmid, qualityTiers[tierType]).then(hit => hit && { type: tierType, url: hit.url })),
|
||||||
return getToken().then(() => getUrlByPlayUrl(songInfo.songmid, br).promise)
|
Promise.resolve(null as any),
|
||||||
}).then(url => ({ type, url }))
|
).then(res => res ?? getToken().then(t => getUrlByPlayUrl(songInfo.songmid, t).promise))
|
||||||
return requestObj
|
return requestObj
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+209
@@ -0,0 +1,209 @@
|
|||||||
|
// 汽水音乐 URL 获取(直连)
|
||||||
|
// 两条取流通道:
|
||||||
|
// 匿名(未登录): seo_track → url_player_info(VOD GetPlayInfo 免登录)→ 未加密试听直链(30s 132k)
|
||||||
|
// 登录态: 设备注册 + X-Argus/X-Ladon 自签 → /luna/pc/track_v2 → 加密音频(cenc-aes-ctr)
|
||||||
|
// → spade key 解密 → /stream/qs/:id 代理端点回明文流(相对地址,路由层负责补全 origin)
|
||||||
|
// 实测(2026-10-01): track_v2 匿名=加密试听;完整曲目需登录 sessionid(抖音扫码 /login/qs/qr)。
|
||||||
|
// 登录请求失败自动降级匿名试听。
|
||||||
|
// 统一接口约定:apis('qs').getMusicUrl(songInfo, type) → { promise, cancelHttp }
|
||||||
|
// promise resolve → { type, url }
|
||||||
|
|
||||||
|
import { httpFetch } from '../request.ts'
|
||||||
|
import musicInfo from '../qs/musicInfo.ts'
|
||||||
|
import { getDevice } from '../qs/device.ts'
|
||||||
|
import { getSessionId } from '../qs/passport.ts'
|
||||||
|
import { signRequest } from '../qs/crypto/sign.ts'
|
||||||
|
import { decryptAudioBuffer, resolveAudioKey } from '../qs/crypto/audiodecrypt.ts'
|
||||||
|
import type { HttpResponse } from '../types.ts'
|
||||||
|
|
||||||
|
// 官方直连接口支持的音质:匿名只有 30 秒试听 128k;登录态取码率最高条目(324k/hi_res 视账号权益)
|
||||||
|
export const supportQualitys = ['128k', '320k']
|
||||||
|
|
||||||
|
const TRACK_V2_URL = 'https://api.qishui.com/luna/pc/track_v2'
|
||||||
|
const STREAM_PREFIX = '/stream/qs/'
|
||||||
|
const STREAM_CACHE_TTL = 10 * 60_000
|
||||||
|
const STREAM_CACHE_MAX = 3
|
||||||
|
|
||||||
|
interface StreamEntry {
|
||||||
|
buffer: Buffer
|
||||||
|
contentType: string
|
||||||
|
bitrate: number
|
||||||
|
size: number
|
||||||
|
resolvedAt: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已解密音频缓存(/url 解析一次,/stream 直接消费;LRU + TTL)
|
||||||
|
const streamCache = new Map<string, StreamEntry>()
|
||||||
|
const inflight = new Map<string, Promise<StreamEntry>>()
|
||||||
|
|
||||||
|
const cachePut = (trackId: string, entry: StreamEntry) => {
|
||||||
|
streamCache.delete(trackId) // 重新插入以刷新 LRU 顺序
|
||||||
|
streamCache.set(trackId, entry)
|
||||||
|
while (streamCache.size > STREAM_CACHE_MAX) {
|
||||||
|
streamCache.delete(streamCache.keys().next().value!)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已缓存的明文音频(/stream/qs/:id 用;缓存未命中返回 null,由路由触发重解析) */
|
||||||
|
export const getCachedStream = (trackId: string): StreamEntry | null => {
|
||||||
|
const entry = streamCache.get(trackId)
|
||||||
|
if (!entry) return null
|
||||||
|
if (Date.now() - entry.resolvedAt > STREAM_CACHE_TTL) {
|
||||||
|
streamCache.delete(trackId)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// 匿名通道:码率最高的未加密条目(带 PlayAuth = 加密,直接回 URL 不可播,跳过)
|
||||||
|
const pickPlainAudioUrl = (playInfoList: any): string | null => {
|
||||||
|
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl && !item.PlayAuth)
|
||||||
|
if (!entries.length) return null
|
||||||
|
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
|
||||||
|
return entries[0].MainPlayUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
const anonymousPreviewUrl = async (trackId: string): Promise<string> => {
|
||||||
|
const body = await musicInfo(trackId).promise
|
||||||
|
const playerInfoUrl = body?.track_player?.url_player_info
|
||||||
|
if (!playerInfoUrl) throw new Error('获取失败')
|
||||||
|
const { statusCode, body: playInfo } = await httpFetch(playerInfoUrl, { method: 'get', timeout: 15000 }).promise
|
||||||
|
const url = statusCode == 200 ? pickPlainAudioUrl(playInfo?.Result?.Data?.PlayInfoList) : null
|
||||||
|
if (!url) throw new Error('获取失败')
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录态通道:注册设备 + argus/ladon 自签 → track_v2 → 解密最高码率音频
|
||||||
|
const buildSignedUrl = (device: Awaited<ReturnType<typeof getDevice>>): string => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
device_platform: 'android',
|
||||||
|
os: 'android',
|
||||||
|
ssmix: 'a',
|
||||||
|
cdid: device.cdid,
|
||||||
|
channel: device.channel,
|
||||||
|
aid: '386088',
|
||||||
|
app_name: 'luna',
|
||||||
|
version_code: '100198030',
|
||||||
|
version_name: '19.8.0',
|
||||||
|
manifest_version_code: '100198030',
|
||||||
|
update_version_code: '100198030',
|
||||||
|
resolution: '1080*1920',
|
||||||
|
dpi: '480',
|
||||||
|
device_type: device.device_type,
|
||||||
|
device_brand: device.device_brand,
|
||||||
|
language: 'zh',
|
||||||
|
os_api: '35',
|
||||||
|
os_version: device.os_version,
|
||||||
|
ac: 'wifi',
|
||||||
|
device_model: device.device_model,
|
||||||
|
device_id: device.device_id,
|
||||||
|
iid: device.install_id,
|
||||||
|
tz_name: 'Asia/Shanghai',
|
||||||
|
tz_offset: '28800',
|
||||||
|
sim_region: 'cn',
|
||||||
|
package: 'com.luna.music',
|
||||||
|
luna_apk_type: 'normal_apk',
|
||||||
|
output_device_type: 'Phone',
|
||||||
|
openudid: device.openudid,
|
||||||
|
_rticket: String(Date.now()),
|
||||||
|
})
|
||||||
|
return `${TRACK_V2_URL}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pickBestEntry = (playInfoList: any) => {
|
||||||
|
const entries = (playInfoList || []).filter(item => item?.MainPlayUrl)
|
||||||
|
if (!entries.length) return null
|
||||||
|
entries.sort((a, b) => (b.Bitrate || 0) - (a.Bitrate || 0))
|
||||||
|
return entries[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSignedStream = async (trackId: string): Promise<StreamEntry> => {
|
||||||
|
const device = await getDevice()
|
||||||
|
const sessionId = getSessionId()
|
||||||
|
const url = buildSignedUrl(device)
|
||||||
|
const requestBody = JSON.stringify({ track_id: trackId, media_type: 'track', queue_type: 'search_one_track', scene_name: 'search' })
|
||||||
|
const headers = await signRequest(url, requestBody)
|
||||||
|
const { statusCode, body } = (await httpFetch(url, {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'User-Agent': 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80',
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'X-Luna-Background-Type': 'foreground',
|
||||||
|
'X-Luna-Is-Background-Req': '0',
|
||||||
|
'X-Luna-Is-Local-User': '1',
|
||||||
|
...(sessionId ? { Cookie: `sessionid=${sessionId}` } : {}),
|
||||||
|
},
|
||||||
|
body: requestBody,
|
||||||
|
timeout: 15000,
|
||||||
|
}).promise) as HttpResponse
|
||||||
|
|
||||||
|
const trackPlayer = (body as any)?.track_player
|
||||||
|
if (statusCode != 200 || !trackPlayer?.url_player_info) {
|
||||||
|
const errMsg = (body as any)?.status_info?.status_msg || (body as any)?.message || ''
|
||||||
|
throw new Error(`track_v2 获取失败: HTTP ${statusCode} ${errMsg}`.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
const playInfoRes = await httpFetch(trackPlayer.url_player_info, { method: 'get', timeout: 15000 }).promise
|
||||||
|
const entry = pickBestEntry((playInfoRes.body as any)?.Result?.Data?.PlayInfoList)
|
||||||
|
if (!entry) throw new Error('无可用音频资源')
|
||||||
|
|
||||||
|
const audioRes = await httpFetch(entry.MainPlayUrl, { method: 'get', timeout: 30000 }).promise
|
||||||
|
const encrypted = audioRes.raw
|
||||||
|
if (!encrypted?.length) throw new Error('音频下载失败')
|
||||||
|
|
||||||
|
let buffer: Buffer = encrypted
|
||||||
|
let contentType = 'audio/mp4'
|
||||||
|
if (entry.PlayAuth) {
|
||||||
|
const hexKey = resolveAudioKey({ spade_a: entry.PlayAuth })
|
||||||
|
const decrypted = decryptAudioBuffer(new Uint8Array(encrypted), hexKey)
|
||||||
|
buffer = Buffer.from(decrypted.buffer)
|
||||||
|
contentType = decrypted.content_type
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamEntry: StreamEntry = {
|
||||||
|
buffer,
|
||||||
|
contentType,
|
||||||
|
bitrate: entry.Bitrate || 0,
|
||||||
|
size: buffer.length,
|
||||||
|
resolvedAt: Date.now(),
|
||||||
|
}
|
||||||
|
cachePut(trackId, streamEntry)
|
||||||
|
return streamEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析明文音频流(/stream/qs/:id 用;先查缓存,未命中重新解析) */
|
||||||
|
export const getStreamBuffer = async (trackId: string): Promise<StreamEntry> => {
|
||||||
|
const cached = getCachedStream(trackId)
|
||||||
|
if (cached) return cached
|
||||||
|
let work = inflight.get(trackId)
|
||||||
|
if (!work) {
|
||||||
|
work = resolveSignedStream(trackId).finally(() => inflight.delete(trackId))
|
||||||
|
inflight.set(trackId, work)
|
||||||
|
}
|
||||||
|
return work
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMusicUrl = (songInfo, type) => {
|
||||||
|
const requestObj = { cancelHttp() {}, promise: null as any }
|
||||||
|
const trackId = String(songInfo.songmid ?? '')
|
||||||
|
const sessionId = getSessionId()
|
||||||
|
if (sessionId) {
|
||||||
|
// 登录态:签名 track_v2 + 服务端解密,/url 阶段完成解析(快速失败),/stream 直接消费缓存
|
||||||
|
requestObj.promise = getStreamBuffer(trackId)
|
||||||
|
.then(entry => {
|
||||||
|
console.log(`[qs/url] ${trackId} 解密流就绪 bitrate=${entry.bitrate} size=${entry.size}`)
|
||||||
|
return { type, url: `${STREAM_PREFIX}${trackId}` }
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.log(`[qs/url] ${trackId} 登录态取流失败,降级匿名试听: ${err.message}`)
|
||||||
|
return anonymousPreviewUrl(trackId).then(url => ({ type, url }))
|
||||||
|
})
|
||||||
|
return requestObj
|
||||||
|
}
|
||||||
|
// 匿名:未加密试听直链
|
||||||
|
requestObj.promise = anonymousPreviewUrl(trackId).then(url => ({ type, url }))
|
||||||
|
return requestObj
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getMusicUrl, getStreamBuffer, getCachedStream }
|
||||||
@@ -2,10 +2,12 @@ import kw from './kw/index.ts'
|
|||||||
import kg from './kg/index.ts'
|
import kg from './kg/index.ts'
|
||||||
import tx from './tx/index.ts'
|
import tx from './tx/index.ts'
|
||||||
import wy from './wy/index.ts'
|
import wy from './wy/index.ts'
|
||||||
|
import qs from './qs/index.ts'
|
||||||
import { supportQualitys as kwQualitys } from './apis/kw.ts'
|
import { supportQualitys as kwQualitys } from './apis/kw.ts'
|
||||||
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
import { supportQualitys as kgQualitys } from './apis/kg.ts'
|
||||||
import { supportQualitys as txQualitys } from './apis/tx.ts'
|
import { supportQualitys as txQualitys } from './apis/tx.ts'
|
||||||
import { supportQualitys as wyQualitys } from './apis/wy.ts'
|
import { supportQualitys as wyQualitys } from './apis/wy.ts'
|
||||||
|
import { supportQualitys as qsQualitys } from './apis/qs.ts'
|
||||||
import type { PlatformModule, SearchResult, SongInfo } from './types.ts'
|
import type { PlatformModule, SearchResult, SongInfo } from './types.ts'
|
||||||
import {
|
import {
|
||||||
sizeFormate,
|
sizeFormate,
|
||||||
@@ -25,6 +27,7 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul
|
|||||||
{ id: 'kg', name: '酷狗音乐', module: kg },
|
{ id: 'kg', name: '酷狗音乐', module: kg },
|
||||||
{ id: 'tx', name: 'QQ音乐', module: tx },
|
{ id: 'tx', name: 'QQ音乐', module: tx },
|
||||||
{ id: 'wy', name: '网易音乐', module: wy },
|
{ id: 'wy', name: '网易音乐', module: wy },
|
||||||
|
{ id: 'qs', name: '汽水音乐', module: qs },
|
||||||
]
|
]
|
||||||
|
|
||||||
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
|
const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module]))
|
||||||
@@ -41,6 +44,7 @@ export const supportQuality: Record<string, string[]> = {
|
|||||||
kg: kgQualitys,
|
kg: kgQualitys,
|
||||||
tx: txQualitys,
|
tx: txQualitys,
|
||||||
wy: wyQualitys,
|
wy: wyQualitys,
|
||||||
|
qs: qsQualitys,
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
|
const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string)
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { b64encode, getCrypto, getSubtle, hash, pkcs7Pad } from "./index.ts";
|
||||||
|
import { ProtobufWriter } from "./protobuf.ts";
|
||||||
|
import { simonEncrypt } from "./simon.ts";
|
||||||
|
import { sm3 } from "./sm3.ts";
|
||||||
|
import type { UnidbgConfig } from "./unidbgconfig.ts";
|
||||||
|
|
||||||
|
const LOW_RAND = new Uint8Array([0xf2, 0x81]);
|
||||||
|
const HIGH_RAND = new Uint8Array([0x61, 0x6f]); // 'a', 'o'
|
||||||
|
const XOR_PREFIX = new Uint8Array([0xf2, 0xf7, 0xfc, 0xff, 0xf2, 0xf7, 0xfc, 0xff]);
|
||||||
|
|
||||||
|
function sm3Prefix6(data: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
return sm3(data).slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeStub(xssStub: string): Uint8Array<ArrayBuffer> {
|
||||||
|
const bytes = new Uint8Array(16);
|
||||||
|
if (xssStub.length >= 32) {
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
bytes[i] = parseInt(xssStub.slice(i * 2, i * 2 + 2), 16) & 0xff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateConstant(code: string): number {
|
||||||
|
const parts = [...code.replace(/\./g, "").padStart(6, "0")].map(Number);
|
||||||
|
const weights = [20480, 2048, 20971520, 2097152, 1342177280, 134217728];
|
||||||
|
return parts.reduce((sum, part, i) => sum + part * weights[i]!, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildProtobuf(
|
||||||
|
query: string,
|
||||||
|
xssStub: string,
|
||||||
|
timestamp: number,
|
||||||
|
config: UnidbgConfig,
|
||||||
|
): Uint8Array<ArrayBuffer> {
|
||||||
|
const params = new URLSearchParams(query);
|
||||||
|
const deviceId = params.get("device_id") ?? "";
|
||||||
|
const versionName = params.get("version_name") ?? "";
|
||||||
|
const deviceType = params.get("device_type") ?? "";
|
||||||
|
const osVersion = params.get("os_version") ?? "";
|
||||||
|
const channel = params.get("channel") ?? "";
|
||||||
|
|
||||||
|
// 空 stub / 空 query 时都对 16 字节全零求 SM3
|
||||||
|
const bodyHash = sm3Prefix6(xssStub === "" ? new Uint8Array(16) : decodeStub(xssStub));
|
||||||
|
const queryHash = sm3Prefix6(
|
||||||
|
query === "" ? new Uint8Array(16) : new TextEncoder().encode(query),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rand = getCrypto().getRandomValues(new Uint32Array(1))[0]! % 0x7fffffff;
|
||||||
|
|
||||||
|
return new ProtobufWriter()
|
||||||
|
.varint(1, 0x20200929 * 2) // magic
|
||||||
|
.varint(2, 2) // version
|
||||||
|
.varint(3, rand)
|
||||||
|
.string(4, config.aid)
|
||||||
|
.string(5, deviceId)
|
||||||
|
.string(6, config.licenseId)
|
||||||
|
.string(7, versionName)
|
||||||
|
.string(8, config.sdkVersion)
|
||||||
|
.varint(9, config.sdkVersionInt)
|
||||||
|
.bytes(10, new Uint8Array(8)) // envcode
|
||||||
|
.varint(11, 0) // platform
|
||||||
|
.varint(12, timestamp * 2) // createTime
|
||||||
|
.bytes(13, bodyHash)
|
||||||
|
.bytes(14, queryHash)
|
||||||
|
.message(15, (sub) => {
|
||||||
|
sub.varint(1, 1).varint(2, 1).varint(3, 1).varint(7, 3348294860);
|
||||||
|
})
|
||||||
|
.string(16, config.secDeviceId ?? "") // sec_device_id
|
||||||
|
.string(20, "none") // pskVersion
|
||||||
|
.varint(21, config.callType)
|
||||||
|
.message(23, (sub) => {
|
||||||
|
sub.string(1, deviceType).string(2, osVersion).string(3, channel).varint(4, calculateConstant(osVersion));
|
||||||
|
})
|
||||||
|
.varint(25, 2)
|
||||||
|
.toBytes();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getArgus(
|
||||||
|
query: string,
|
||||||
|
xssStub: string,
|
||||||
|
timestamp: number,
|
||||||
|
config: UnidbgConfig,
|
||||||
|
): Promise<string> {
|
||||||
|
const { signKey } = config;
|
||||||
|
if (signKey.length !== 32) {
|
||||||
|
throw new Error(`Sign key must be 32 bytes, got ${signKey.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protobuf = pkcs7Pad(buildProtobuf(query, xssStub, timestamp, config), 16);
|
||||||
|
|
||||||
|
const sm3Input = new Uint8Array(signKey.length * 2 + LOW_RAND.length + HIGH_RAND.length);
|
||||||
|
sm3Input.set(signKey, 0);
|
||||||
|
sm3Input.set(LOW_RAND, signKey.length);
|
||||||
|
sm3Input.set(HIGH_RAND, signKey.length + LOW_RAND.length);
|
||||||
|
sm3Input.set(signKey, signKey.length + LOW_RAND.length + HIGH_RAND.length);
|
||||||
|
|
||||||
|
const sm3Output = sm3(sm3Input);
|
||||||
|
const keyView = new DataView(sm3Output.buffer, sm3Output.byteOffset, sm3Output.byteLength);
|
||||||
|
const simonKey: [bigint, bigint, bigint, bigint] = [
|
||||||
|
keyView.getBigUint64(0, true),
|
||||||
|
keyView.getBigUint64(8, true),
|
||||||
|
keyView.getBigUint64(16, true),
|
||||||
|
keyView.getBigUint64(24, true),
|
||||||
|
];
|
||||||
|
|
||||||
|
const encrypted = new Uint8Array(protobuf.length);
|
||||||
|
const pbView = new DataView(protobuf.buffer, protobuf.byteOffset, protobuf.byteLength);
|
||||||
|
const encView = new DataView(encrypted.buffer);
|
||||||
|
for (let offset = 0; offset < protobuf.length; offset += 16) {
|
||||||
|
const [low, high] = simonEncrypt(
|
||||||
|
[pbView.getBigUint64(offset, true), pbView.getBigUint64(offset + 8, true)],
|
||||||
|
simonKey,
|
||||||
|
);
|
||||||
|
encView.setBigUint64(offset, low, true);
|
||||||
|
encView.setBigUint64(offset + 8, high, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = new Uint8Array(XOR_PREFIX.length + encrypted.length);
|
||||||
|
data.set(XOR_PREFIX);
|
||||||
|
data.set(encrypted, XOR_PREFIX.length);
|
||||||
|
for (let i = XOR_PREFIX.length; i < data.length; i++) {
|
||||||
|
data[i]! ^= data[i % 8]!;
|
||||||
|
}
|
||||||
|
data.reverse();
|
||||||
|
|
||||||
|
const header = new Uint8Array([0xa6, 0x6e, 0xad, 0x9f, 0x77, 0x01, 0xd0, 0x0c, 0x18]);
|
||||||
|
const plaintext = new Uint8Array(header.length + data.length + HIGH_RAND.length);
|
||||||
|
plaintext.set(header);
|
||||||
|
plaintext.set(data, header.length);
|
||||||
|
plaintext.set(HIGH_RAND, header.length + data.length);
|
||||||
|
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const aesKey = await subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
await hash.md5bytes(signKey.slice(0, 16)),
|
||||||
|
{ name: "AES-CBC" },
|
||||||
|
false,
|
||||||
|
["encrypt"],
|
||||||
|
);
|
||||||
|
const iv = await hash.md5bytes(signKey.slice(16));
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await subtle.encrypt({ name: "AES-CBC", iv }, aesKey, plaintext),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = new Uint8Array(LOW_RAND.length + ciphertext.length);
|
||||||
|
result.set(LOW_RAND);
|
||||||
|
result.set(ciphertext, LOW_RAND.length);
|
||||||
|
return b64encode(result.buffer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
// CENC 音频解密(AES-CTR 逐样本)—— 移植自 guowenye/qishui-api src/audioDecryptor.js
|
||||||
|
// spade_a/PlayAuth → 32 位 hex key;密音频(m4a CENC / flac)→ 明文音频 Buffer
|
||||||
|
import { createDecipheriv } from 'node:crypto'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const encryptedBoxTypes = new Set(['senc', 'saio', 'saiz', 'sinf', 'schi', 'tenc', 'schm', 'frma'])
|
||||||
|
const containerBoxTypes = new Set(['moov', 'trak', 'mdia', 'minf', 'stbl', 'stsd'])
|
||||||
|
const maxSampleCount = 200000
|
||||||
|
const maxChunkCount = 200000
|
||||||
|
const maxStscEntryCount = 20000
|
||||||
|
|
||||||
|
function decodeBase36(codePoint) {
|
||||||
|
if (codePoint >= 48 && codePoint <= 57) return codePoint - 48
|
||||||
|
if (codePoint >= 97 && codePoint <= 122) return codePoint - 97 + 10
|
||||||
|
return 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
function bitCount(number) {
|
||||||
|
let value = number - ((number >> 1) & 0x55555555)
|
||||||
|
value = (value & 0x33333333) + ((value >> 2) & 0x33333333)
|
||||||
|
return (((value + (value >> 4)) & 0xF0F0F0F) * 0x1010101) >> 24
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptSpadeInner(spadeKey) {
|
||||||
|
const result = new Uint8Array(spadeKey)
|
||||||
|
const buffer = new Uint8Array(2 + spadeKey.length)
|
||||||
|
buffer.set([0xFA, 0x55], 0)
|
||||||
|
buffer.set(spadeKey, 2)
|
||||||
|
|
||||||
|
for (let index = 0; index < result.length; index += 1) {
|
||||||
|
let value = (spadeKey[index] ^ buffer[index]) - bitCount(index) - 21
|
||||||
|
while (value < 0) value += 0xFF
|
||||||
|
result[index] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptSpade(spadeKeyBytes) {
|
||||||
|
const spadeKeyLength = spadeKeyBytes.length
|
||||||
|
if (spadeKeyLength < 3) return ''
|
||||||
|
const paddingLength = (spadeKeyBytes[0] ^ spadeKeyBytes[1] ^ spadeKeyBytes[2]) - 48
|
||||||
|
if (spadeKeyLength < paddingLength + 2) return ''
|
||||||
|
const innerInput = spadeKeyBytes.slice(1, spadeKeyLength - paddingLength)
|
||||||
|
const decodedBuffer = decryptSpadeInner(innerInput)
|
||||||
|
if (decodedBuffer.length === 0) return ''
|
||||||
|
const skipBytes = decodeBase36(decodedBuffer[0])
|
||||||
|
const decodedMessageLength = spadeKeyLength - paddingLength - 2
|
||||||
|
const endIndex = 1 + decodedMessageLength - skipBytes
|
||||||
|
if (endIndex > decodedBuffer.length) return ''
|
||||||
|
return Buffer.from(decodedBuffer.slice(1, endIndex)).toString('utf8')
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptSpadeA(spadeA) {
|
||||||
|
try {
|
||||||
|
return decryptSpade(new Uint8Array(Buffer.from(String(spadeA || '').trim(), 'base64')))
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHexKey(value) {
|
||||||
|
return typeof value === 'string' && /^[a-f0-9]{32}$/i.test(value.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAudioKey(source: any = {}) {
|
||||||
|
const rawHexKey = source.hex_key || source.key
|
||||||
|
if (isHexKey(rawHexKey)) return rawHexKey.trim().toLowerCase()
|
||||||
|
|
||||||
|
const rawSpadeA = source.spade_a || source.spadeA
|
||||||
|
if (!rawSpadeA) throw new Error('spade_a 或 hex_key 不能为空')
|
||||||
|
const hexKey = decryptSpadeA(rawSpadeA).trim()
|
||||||
|
if (!isHexKey(hexKey)) throw new Error('spade_a 或 hex_key 无效')
|
||||||
|
return hexKey.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToBytes(hex) {
|
||||||
|
if (!isHexKey(hex)) throw new Error('hex_key 必须是 32 位十六进制 AES-128 密钥')
|
||||||
|
return Buffer.from(hex, 'hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
function asUint8Array(input) {
|
||||||
|
if (input instanceof Uint8Array) return input
|
||||||
|
if (Buffer.isBuffer(input)) return new Uint8Array(input.buffer, input.byteOffset, input.byteLength)
|
||||||
|
if (input instanceof ArrayBuffer) return new Uint8Array(input)
|
||||||
|
throw new Error('音频数据必须是 Buffer、Uint8Array 或 ArrayBuffer')
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatUint8Arrays(arrays: any[]) {
|
||||||
|
const totalLength = arrays.reduce((sum, array) => sum + array.length, 0)
|
||||||
|
const result = new Uint8Array(totalLength)
|
||||||
|
let offset = 0
|
||||||
|
for (const array of arrays) {
|
||||||
|
result.set(array, offset)
|
||||||
|
offset += array.length
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function readUint32BE(fileData, offset) {
|
||||||
|
if (!Number.isInteger(offset) || offset < 0 || offset + 4 > fileData.length) {
|
||||||
|
throw new Error('音频容器结构无效')
|
||||||
|
}
|
||||||
|
return new DataView(fileData.buffer, fileData.byteOffset, fileData.byteLength).getUint32(offset, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeUint32BE(value) {
|
||||||
|
const output = new Uint8Array(4)
|
||||||
|
new DataView(output.buffer).setUint32(0, value, false)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAscii(fileData, start, end) {
|
||||||
|
return Buffer.from(fileData.subarray(start, end)).toString('ascii')
|
||||||
|
}
|
||||||
|
|
||||||
|
class MP4Box {
|
||||||
|
offset: number
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
data: Uint8Array
|
||||||
|
|
||||||
|
constructor(fileData: Uint8Array, offset: number) {
|
||||||
|
this.offset = offset
|
||||||
|
this.size = readUint32BE(fileData, offset)
|
||||||
|
this.type = readAscii(fileData, offset + 4, offset + 8)
|
||||||
|
this.data = fileData.subarray(offset + 8, offset + this.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
static findBox(fileData: Uint8Array, boxType: string, offset = 0, end: number | null = null) {
|
||||||
|
const searchEnd = end === null ? fileData.length : end
|
||||||
|
let position = offset
|
||||||
|
while (position < searchEnd) {
|
||||||
|
if (position + 8 > searchEnd) break
|
||||||
|
const boxSize = readUint32BE(fileData, position)
|
||||||
|
if (boxSize === 0 || boxSize > searchEnd - position || boxSize < 8) break
|
||||||
|
const currentType = readAscii(fileData, position + 4, position + 8)
|
||||||
|
if (currentType === boxType) return new MP4Box(fileData, position)
|
||||||
|
position += boxSize
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireBox(box, name) {
|
||||||
|
if (!box) throw new Error(`音频容器缺少 ${name} box`)
|
||||||
|
return box
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStsz(stszData) {
|
||||||
|
const sampleSize = readUint32BE(stszData, 4)
|
||||||
|
const sampleCount = readUint32BE(stszData, 8)
|
||||||
|
if (sampleCount > maxSampleCount) throw new Error('音频样本数量超过限制')
|
||||||
|
if (sampleSize !== 0) return new Array(sampleCount).fill(sampleSize)
|
||||||
|
const requiredLength = 12 + sampleCount * 4
|
||||||
|
if (requiredLength > stszData.length) throw new Error('stsz box 样本大小数据不完整')
|
||||||
|
const sampleSizes: number[] = []
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
sampleSizes.push(readUint32BE(stszData, 12 + index * 4))
|
||||||
|
}
|
||||||
|
return sampleSizes
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseStsc(stscData) {
|
||||||
|
const entryCount = readUint32BE(stscData, 4)
|
||||||
|
if (entryCount > maxStscEntryCount) throw new Error('stsc entry 数量超过限制')
|
||||||
|
const requiredLength = 8 + entryCount * 12
|
||||||
|
if (requiredLength > stscData.length) throw new Error('stsc box 数据不完整')
|
||||||
|
const entries: any[] = []
|
||||||
|
for (let index = 0; index < entryCount; index += 1) {
|
||||||
|
const baseOffset = 8 + index * 12
|
||||||
|
entries.push({
|
||||||
|
firstChunk: readUint32BE(stscData, baseOffset),
|
||||||
|
samplesPerChunk: readUint32BE(stscData, baseOffset + 4),
|
||||||
|
id: readUint32BE(stscData, baseOffset + 8),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSenc(sencData) {
|
||||||
|
const sampleCount = readUint32BE(sencData, 4)
|
||||||
|
if (sampleCount > maxSampleCount) throw new Error('senc 样本数量超过限制')
|
||||||
|
const requiredLength = 8 + sampleCount * 8
|
||||||
|
if (requiredLength > sencData.length) throw new Error('senc box IV 数据不完整')
|
||||||
|
const ivs: Uint8Array[] = []
|
||||||
|
let position = 8
|
||||||
|
for (let index = 0; index < sampleCount; index += 1) {
|
||||||
|
const iv = new Uint8Array(16)
|
||||||
|
iv.set(sencData.subarray(position, position + 8))
|
||||||
|
ivs.push(iv)
|
||||||
|
position += 8
|
||||||
|
}
|
||||||
|
return ivs
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateChunkOffsets(sampleSizes, stscEntries, chunkCount, baseOffset) {
|
||||||
|
const offsets: number[] = []
|
||||||
|
let currentOffset = baseOffset
|
||||||
|
let sampleIndex = 0
|
||||||
|
for (let chunkIndex = 1; chunkIndex <= chunkCount; chunkIndex += 1) {
|
||||||
|
offsets.push(currentOffset)
|
||||||
|
let samplesPerChunk = 0
|
||||||
|
for (let entryIndex = 0; entryIndex < stscEntries.length; entryIndex += 1) {
|
||||||
|
const currentEntry = stscEntries[entryIndex]
|
||||||
|
const nextEntry = stscEntries[entryIndex + 1]
|
||||||
|
if (chunkIndex >= currentEntry.firstChunk && (!nextEntry || chunkIndex < nextEntry.firstChunk)) {
|
||||||
|
samplesPerChunk = currentEntry.samplesPerChunk
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let sampleOffset = 0; sampleOffset < samplesPerChunk; sampleOffset += 1) {
|
||||||
|
if (sampleIndex < sampleSizes.length) currentOffset += sampleSizes[sampleIndex]
|
||||||
|
sampleIndex += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return offsets
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStco(stcoData, offsets) {
|
||||||
|
const chunkCount = readUint32BE(stcoData, 4)
|
||||||
|
if (offsets.length < chunkCount) throw new Error('stco 偏移数量不足')
|
||||||
|
const header = stcoData.subarray(0, 8)
|
||||||
|
const body = new Uint8Array(chunkCount * 4)
|
||||||
|
const view = new DataView(body.buffer)
|
||||||
|
for (let index = 0; index < chunkCount; index += 1) view.setUint32(index * 4, offsets[index], false)
|
||||||
|
return concatUint8Arrays([header, body])
|
||||||
|
}
|
||||||
|
|
||||||
|
function processBoxTree(fileData, offset, size, newMdatOffset, context) {
|
||||||
|
const parts: Uint8Array[] = []
|
||||||
|
let position = offset + 8
|
||||||
|
const end = offset + size
|
||||||
|
|
||||||
|
while (position < end) {
|
||||||
|
if (position + 8 > end) {
|
||||||
|
parts.push(fileData.subarray(position, end))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const boxSize = readUint32BE(fileData, position)
|
||||||
|
if (boxSize < 8 || boxSize > end - position) {
|
||||||
|
parts.push(fileData.subarray(position, end))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const type = readAscii(fileData, position + 4, position + 8)
|
||||||
|
|
||||||
|
if (encryptedBoxTypes.has(type)) {
|
||||||
|
position += boxSize
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'enca') {
|
||||||
|
const inner = processBoxTree(fileData, position, boxSize, newMdatOffset, context)
|
||||||
|
parts.push(writeUint32BE(inner.length + 8), Buffer.from('mp4a'), inner)
|
||||||
|
position += boxSize
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'stco') {
|
||||||
|
const offsets = calculateChunkOffsets(context.sampleSizes, context.stscEntries, context.chunkCount, newMdatOffset)
|
||||||
|
const updatedBody = updateStco(fileData.subarray(position + 8, position + boxSize), offsets)
|
||||||
|
parts.push(writeUint32BE(updatedBody.length + 8), Buffer.from('stco'), updatedBody)
|
||||||
|
position += boxSize
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerBoxTypes.has(type)) {
|
||||||
|
const inner = processBoxTree(fileData, position, boxSize, newMdatOffset, context)
|
||||||
|
parts.push(writeUint32BE(inner.length + 8), Buffer.from(type), inner)
|
||||||
|
position += boxSize
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.push(fileData.subarray(position, position + boxSize))
|
||||||
|
position += boxSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return concatUint8Arrays(parts)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scanForFlacMetadata(stsdData) {
|
||||||
|
const marker = Buffer.from('dfLa')
|
||||||
|
for (let index = 4; index < stsdData.length - 4; index += 1) {
|
||||||
|
if (
|
||||||
|
stsdData[index] === marker[0]
|
||||||
|
&& stsdData[index + 1] === marker[1]
|
||||||
|
&& stsdData[index + 2] === marker[2]
|
||||||
|
&& stsdData[index + 3] === marker[3]
|
||||||
|
) {
|
||||||
|
const boxSize = readUint32BE(stsdData, index - 4)
|
||||||
|
if (boxSize >= 8 && index - 4 + boxSize <= stsdData.length) {
|
||||||
|
return stsdData.subarray(index + 4, index - 4 + boxSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptCtr(encryptedSample, keyBytes, iv) {
|
||||||
|
const decipher = createDecipheriv(`aes-${keyBytes.length * 8}-ctr`, keyBytes, Buffer.from(iv))
|
||||||
|
decipher.setAutoPadding(false)
|
||||||
|
return Buffer.concat([decipher.update(Buffer.from(encryptedSample)), decipher.final()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function decryptAudioBuffer(input, hexKey) {
|
||||||
|
const fileData = asUint8Array(input)
|
||||||
|
const keyBytes = hexToBytes(hexKey)
|
||||||
|
|
||||||
|
const ftyp = MP4Box.findBox(fileData, 'ftyp')
|
||||||
|
const moov = requireBox(MP4Box.findBox(fileData, 'moov'), 'moov')
|
||||||
|
const trak = requireBox(MP4Box.findBox(fileData, 'trak', moov.offset + 8, moov.offset + moov.size), 'trak')
|
||||||
|
const mdia = requireBox(MP4Box.findBox(fileData, 'mdia', trak.offset + 8, trak.offset + trak.size), 'mdia')
|
||||||
|
const minf = requireBox(MP4Box.findBox(fileData, 'minf', mdia.offset + 8, mdia.offset + mdia.size), 'minf')
|
||||||
|
const stbl = requireBox(MP4Box.findBox(fileData, 'stbl', minf.offset + 8, minf.offset + minf.size), 'stbl')
|
||||||
|
const stsd = requireBox(MP4Box.findBox(fileData, 'stsd', stbl.offset + 8, stbl.offset + stbl.size), 'stsd')
|
||||||
|
const stsz = requireBox(MP4Box.findBox(fileData, 'stsz', stbl.offset + 8, stbl.offset + stbl.size), 'stsz')
|
||||||
|
const stsc = requireBox(MP4Box.findBox(fileData, 'stsc', stbl.offset + 8, stbl.offset + stbl.size), 'stsc')
|
||||||
|
const stco = requireBox(MP4Box.findBox(fileData, 'stco', stbl.offset + 8, stbl.offset + stbl.size), 'stco')
|
||||||
|
const mdat = requireBox(MP4Box.findBox(fileData, 'mdat'), 'mdat')
|
||||||
|
|
||||||
|
let senc = MP4Box.findBox(fileData, 'senc', stbl.offset + 8, stbl.offset + stbl.size)
|
||||||
|
if (!senc) senc = MP4Box.findBox(fileData, 'senc', moov.offset + 8, moov.offset + moov.size)
|
||||||
|
requireBox(senc, 'senc')
|
||||||
|
|
||||||
|
const sampleSizes = parseStsz(stsz.data)
|
||||||
|
const stscEntries = parseStsc(stsc.data)
|
||||||
|
const chunkCount = readUint32BE(stco.data, 4)
|
||||||
|
if (chunkCount > maxChunkCount) throw new Error('音频 chunk 数量超过限制')
|
||||||
|
if (8 + chunkCount * 4 > stco.data.length) throw new Error('stco box 数据不完整')
|
||||||
|
const ivs = parseSenc(requireBox(senc, 'senc').data)
|
||||||
|
if (ivs.length < sampleSizes.length) throw new Error('senc IV 数量少于样本数量')
|
||||||
|
const encryptedPayloadSize = sampleSizes.reduce((sum, sampleSize) => sum + sampleSize, 0)
|
||||||
|
if (encryptedPayloadSize > mdat.size - 8) throw new Error('样本总大小超过 mdat 数据范围')
|
||||||
|
|
||||||
|
const decryptedSamples: Uint8Array[] = []
|
||||||
|
let sampleOffset = mdat.offset + 8
|
||||||
|
for (let index = 0; index < sampleSizes.length; index += 1) {
|
||||||
|
const sampleSize = sampleSizes[index]
|
||||||
|
if (sampleOffset + sampleSize > fileData.length) throw new Error('音频样本数据不完整')
|
||||||
|
decryptedSamples.push(decryptCtr(fileData.subarray(sampleOffset, sampleOffset + sampleSize), keyBytes, ivs[index]))
|
||||||
|
sampleOffset += sampleSize
|
||||||
|
}
|
||||||
|
|
||||||
|
const flacMetadata = scanForFlacMetadata(stsd.data)
|
||||||
|
if (flacMetadata) {
|
||||||
|
const metadataStart = flacMetadata.length > 4 ? 4 : 0
|
||||||
|
const outputBuffer = Buffer.from(concatUint8Arrays([Buffer.from('fLaC'), flacMetadata.subarray(metadataStart), ...decryptedSamples]))
|
||||||
|
return {
|
||||||
|
buffer: outputBuffer,
|
||||||
|
extension: '.flac',
|
||||||
|
content_type: 'audio/flac',
|
||||||
|
codec: 'flac',
|
||||||
|
sample_count: sampleSizes.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = { sampleSizes, stscEntries, chunkCount }
|
||||||
|
const ftypSize = ftyp ? ftyp.size : 0
|
||||||
|
const dummyMoov = processBoxTree(fileData, moov.offset, moov.size, 0, context)
|
||||||
|
const newMdatOffset = ftypSize + dummyMoov.length + 16
|
||||||
|
const cleanMoovData = processBoxTree(fileData, moov.offset, moov.size, newMdatOffset, context)
|
||||||
|
const cleanMoov = concatUint8Arrays([writeUint32BE(cleanMoovData.length + 8), Buffer.from('moov'), cleanMoovData])
|
||||||
|
const mdatData = concatUint8Arrays(decryptedSamples)
|
||||||
|
const newMdat = concatUint8Arrays([writeUint32BE(mdatData.length + 8), Buffer.from('mdat'), mdatData])
|
||||||
|
const finalParts: Uint8Array[] = []
|
||||||
|
if (ftyp) finalParts.push(fileData.subarray(ftyp.offset, ftyp.offset + ftyp.size))
|
||||||
|
finalParts.push(cleanMoov, newMdat)
|
||||||
|
|
||||||
|
return {
|
||||||
|
buffer: Buffer.from(concatUint8Arrays(finalParts)),
|
||||||
|
extension: '.m4a',
|
||||||
|
content_type: 'audio/mp4',
|
||||||
|
codec: 'mp4',
|
||||||
|
sample_count: sampleSizes.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
decryptSpadeA,
|
||||||
|
isHexKey,
|
||||||
|
resolveAudioKey,
|
||||||
|
decryptAudioBuffer,
|
||||||
|
scanForFlacMetadata,
|
||||||
|
MP4Box,
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import md5 from "./md5.ts";
|
||||||
|
import { sm3 } from "./sm3.ts";
|
||||||
|
|
||||||
|
export function getCrypto(): Crypto {
|
||||||
|
const c = globalThis.crypto as Crypto | undefined;
|
||||||
|
if (!c?.subtle) {
|
||||||
|
throw new Error("Crypto API不可用,请检查浏览器版本是否支持该API");
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSubtle(): SubtleCrypto {
|
||||||
|
return getCrypto().subtle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64decode(b64: string): ArrayBuffer {
|
||||||
|
const binaryString = atob(b64);
|
||||||
|
const len = binaryString.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function b64encode(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const chunkSize = 0x8000;
|
||||||
|
const chunks: string[] = [];
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
chunks.push(
|
||||||
|
String.fromCharCode(...bytes.subarray(i, i + chunkSize)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return btoa(chunks.join(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unhex(hex: string): ArrayBuffer {
|
||||||
|
if (hex.length % 2 !== 0) {
|
||||||
|
throw new Error("Invalid hex string");
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
const byte = parseInt(hex.slice(i, i + 2), 16);
|
||||||
|
if (Number.isNaN(byte)) {
|
||||||
|
throw new Error("Invalid hex string");
|
||||||
|
}
|
||||||
|
bytes[i / 2] = byte;
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hex(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let hexString = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
hexString += bytes[i]!.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return hexString;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pkcs7Pad(data: Uint8Array, blockSize = 16): Uint8Array<ArrayBuffer> {
|
||||||
|
const padLength = blockSize - (data.length % blockSize);
|
||||||
|
const padded = new Uint8Array(data.length + padLength);
|
||||||
|
padded.set(data);
|
||||||
|
padded.fill(padLength, data.length);
|
||||||
|
return padded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomString(length: number): string {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let result = "";
|
||||||
|
const array = new Uint8Array(length);
|
||||||
|
getCrypto().getRandomValues(array);
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(array[i]! % chars.length);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HashInput = ArrayBuffer | Uint8Array<ArrayBuffer> | string;
|
||||||
|
|
||||||
|
function toBytes(input: HashInput): Uint8Array<ArrayBuffer> {
|
||||||
|
if (typeof input === "string") {
|
||||||
|
return new TextEncoder().encode(input);
|
||||||
|
}
|
||||||
|
if (input instanceof Uint8Array) {
|
||||||
|
return new Uint8Array(input);
|
||||||
|
}
|
||||||
|
return new Uint8Array(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hash = {
|
||||||
|
sha256: async (input: HashInput): Promise<string> => {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const digest = await subtle.digest("SHA-256", toBytes(input));
|
||||||
|
return hex(digest);
|
||||||
|
},
|
||||||
|
sha256bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
return subtle.digest("SHA-256", toBytes(input));
|
||||||
|
},
|
||||||
|
sha512: async (input: HashInput): Promise<string> => {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
const digest = await subtle.digest("SHA-512", toBytes(input));
|
||||||
|
return hex(digest);
|
||||||
|
},
|
||||||
|
sha512bytes: async (input: HashInput): Promise<ArrayBuffer> => {
|
||||||
|
const subtle = getSubtle();
|
||||||
|
return subtle.digest("SHA-512", toBytes(input));
|
||||||
|
},
|
||||||
|
md5: async (input: HashInput): Promise<string> => md5(
|
||||||
|
typeof input === "string" ? input : toBytes(input).buffer,
|
||||||
|
),
|
||||||
|
md5bytes: async (input: HashInput): Promise<ArrayBuffer> => unhex(md5(
|
||||||
|
typeof input === "string" ? input : toBytes(input).buffer,
|
||||||
|
)),
|
||||||
|
sm3: async (input: HashInput): Promise<string> => hex(sm3(toBytes(input)).buffer),
|
||||||
|
sm3bytes: async (input: HashInput): Promise<ArrayBuffer> => sm3(toBytes(input)).buffer,
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { b64encode, getCrypto } from "./index.ts";
|
||||||
|
import { generateLadonKey, speckEncrypt } from "./speck.ts";
|
||||||
|
import type { UnidbgConfig } from "./unidbgconfig.ts";
|
||||||
|
|
||||||
|
export async function getLadon(timestamp: number, config: UnidbgConfig): Promise<string> {
|
||||||
|
const randomBytes = getCrypto().getRandomValues(new Uint8Array(4));
|
||||||
|
|
||||||
|
const plaintext = new TextEncoder().encode(
|
||||||
|
`${timestamp}-${config.licenseId}-${config.aid}`,
|
||||||
|
);
|
||||||
|
const key = await generateLadonKey(randomBytes, config.aid);
|
||||||
|
const encrypted = speckEncrypt(key, plaintext);
|
||||||
|
|
||||||
|
const result = new Uint8Array(randomBytes.length + encrypted.length);
|
||||||
|
result.set(randomBytes);
|
||||||
|
result.set(encrypted, randomBytes.length);
|
||||||
|
return b64encode(result.buffer);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
function rotateLeft(value: number, shiftBits: number): number {
|
||||||
|
return (value << shiftBits) | (value >>> (32 - shiftBits));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUnsigned(left: number, right: number): number {
|
||||||
|
return (left + right) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function f(x: number, y: number, z: number): number {
|
||||||
|
return (x & y) | (~x & z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function g(x: number, y: number, z: number): number {
|
||||||
|
return (x & z) | (y & ~z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function h(x: number, y: number, z: number): number {
|
||||||
|
return x ^ y ^ z;
|
||||||
|
}
|
||||||
|
|
||||||
|
function i(x: number, y: number, z: number): number {
|
||||||
|
return y ^ (x | ~z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ff(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
||||||
|
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(f(b, c, d), x), ac)), s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function gg(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
||||||
|
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(g(b, c, d), x), ac)), s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hh(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
||||||
|
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(h(b, c, d), x), ac)), s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ii(a: number, b: number, c: number, d: number, x: number, s: number, ac: number): number {
|
||||||
|
return addUnsigned(rotateLeft(addUnsigned(a, addUnsigned(addUnsigned(i(b, c, d), x), ac)), s), b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToWordArray(bytes: Uint8Array): number[] {
|
||||||
|
const wordCount = Math.ceil((bytes.length + 9) / 64) * 16;
|
||||||
|
const words = new Array<number>(wordCount).fill(0);
|
||||||
|
|
||||||
|
for (let byteIndex = 0; byteIndex < bytes.length; byteIndex++) {
|
||||||
|
const wordIndex = Math.floor(byteIndex / 4);
|
||||||
|
const bytePosition = (byteIndex % 4) * 8;
|
||||||
|
words[wordIndex] = words[wordIndex]! | (bytes[byteIndex]! << bytePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingWordIndex = Math.floor(bytes.length / 4);
|
||||||
|
const paddingBytePosition = (bytes.length % 4) * 8;
|
||||||
|
words[paddingWordIndex] = words[paddingWordIndex]! | (0x80 << paddingBytePosition);
|
||||||
|
words[wordCount - 2] = bytes.length << 3;
|
||||||
|
words[wordCount - 1] = bytes.length >>> 29;
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wordToHex(value: number): string {
|
||||||
|
let hex = "";
|
||||||
|
for (let byteIndex = 0; byteIndex < 4; byteIndex++) {
|
||||||
|
const byte = (value >>> (byteIndex * 8)) & 0xff;
|
||||||
|
hex += byte.toString(16).padStart(2, "0");
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 MD5 摘要。
|
||||||
|
*/
|
||||||
|
export default function md5(input: string | ArrayBuffer): string {
|
||||||
|
const bytes = typeof input === "string" ? new TextEncoder().encode(input) : new Uint8Array(input);
|
||||||
|
const words = convertToWordArray(bytes);
|
||||||
|
|
||||||
|
let a = 0x67452301;
|
||||||
|
let b = 0xefcdab89;
|
||||||
|
let c = 0x98badcfe;
|
||||||
|
let d = 0x10325476;
|
||||||
|
|
||||||
|
for (let k = 0; k < words.length; k += 16) {
|
||||||
|
const aa = a;
|
||||||
|
const bb = b;
|
||||||
|
const cc = c;
|
||||||
|
const dd = d;
|
||||||
|
|
||||||
|
a = ff(a, b, c, d, words[k]!, 7, 0xd76aa478);
|
||||||
|
d = ff(d, a, b, c, words[k + 1]!, 12, 0xe8c7b756);
|
||||||
|
c = ff(c, d, a, b, words[k + 2]!, 17, 0x242070db);
|
||||||
|
b = ff(b, c, d, a, words[k + 3]!, 22, 0xc1bdceee);
|
||||||
|
a = ff(a, b, c, d, words[k + 4]!, 7, 0xf57c0faf);
|
||||||
|
d = ff(d, a, b, c, words[k + 5]!, 12, 0x4787c62a);
|
||||||
|
c = ff(c, d, a, b, words[k + 6]!, 17, 0xa8304613);
|
||||||
|
b = ff(b, c, d, a, words[k + 7]!, 22, 0xfd469501);
|
||||||
|
a = ff(a, b, c, d, words[k + 8]!, 7, 0x698098d8);
|
||||||
|
d = ff(d, a, b, c, words[k + 9]!, 12, 0x8b44f7af);
|
||||||
|
c = ff(c, d, a, b, words[k + 10]!, 17, 0xffff5bb1);
|
||||||
|
b = ff(b, c, d, a, words[k + 11]!, 22, 0x895cd7be);
|
||||||
|
a = ff(a, b, c, d, words[k + 12]!, 7, 0x6b901122);
|
||||||
|
d = ff(d, a, b, c, words[k + 13]!, 12, 0xfd987193);
|
||||||
|
c = ff(c, d, a, b, words[k + 14]!, 17, 0xa679438e);
|
||||||
|
b = ff(b, c, d, a, words[k + 15]!, 22, 0x49b40821);
|
||||||
|
|
||||||
|
a = gg(a, b, c, d, words[k + 1]!, 5, 0xf61e2562);
|
||||||
|
d = gg(d, a, b, c, words[k + 6]!, 9, 0xc040b340);
|
||||||
|
c = gg(c, d, a, b, words[k + 11]!, 14, 0x265e5a51);
|
||||||
|
b = gg(b, c, d, a, words[k]!, 20, 0xe9b6c7aa);
|
||||||
|
a = gg(a, b, c, d, words[k + 5]!, 5, 0xd62f105d);
|
||||||
|
d = gg(d, a, b, c, words[k + 10]!, 9, 0x02441453);
|
||||||
|
c = gg(c, d, a, b, words[k + 15]!, 14, 0xd8a1e681);
|
||||||
|
b = gg(b, c, d, a, words[k + 4]!, 20, 0xe7d3fbc8);
|
||||||
|
a = gg(a, b, c, d, words[k + 9]!, 5, 0x21e1cde6);
|
||||||
|
d = gg(d, a, b, c, words[k + 14]!, 9, 0xc33707d6);
|
||||||
|
c = gg(c, d, a, b, words[k + 3]!, 14, 0xf4d50d87);
|
||||||
|
b = gg(b, c, d, a, words[k + 8]!, 20, 0x455a14ed);
|
||||||
|
a = gg(a, b, c, d, words[k + 13]!, 5, 0xa9e3e905);
|
||||||
|
d = gg(d, a, b, c, words[k + 2]!, 9, 0xfcefa3f8);
|
||||||
|
c = gg(c, d, a, b, words[k + 7]!, 14, 0x676f02d9);
|
||||||
|
b = gg(b, c, d, a, words[k + 12]!, 20, 0x8d2a4c8a);
|
||||||
|
|
||||||
|
a = hh(a, b, c, d, words[k + 5]!, 4, 0xfffa3942);
|
||||||
|
d = hh(d, a, b, c, words[k + 8]!, 11, 0x8771f681);
|
||||||
|
c = hh(c, d, a, b, words[k + 11]!, 16, 0x6d9d6122);
|
||||||
|
b = hh(b, c, d, a, words[k + 14]!, 23, 0xfde5380c);
|
||||||
|
a = hh(a, b, c, d, words[k + 1]!, 4, 0xa4beea44);
|
||||||
|
d = hh(d, a, b, c, words[k + 4]!, 11, 0x4bdecfa9);
|
||||||
|
c = hh(c, d, a, b, words[k + 7]!, 16, 0xf6bb4b60);
|
||||||
|
b = hh(b, c, d, a, words[k + 10]!, 23, 0xbebfbc70);
|
||||||
|
a = hh(a, b, c, d, words[k + 13]!, 4, 0x289b7ec6);
|
||||||
|
d = hh(d, a, b, c, words[k]!, 11, 0xeaa127fa);
|
||||||
|
c = hh(c, d, a, b, words[k + 3]!, 16, 0xd4ef3085);
|
||||||
|
b = hh(b, c, d, a, words[k + 6]!, 23, 0x04881d05);
|
||||||
|
a = hh(a, b, c, d, words[k + 9]!, 4, 0xd9d4d039);
|
||||||
|
d = hh(d, a, b, c, words[k + 12]!, 11, 0xe6db99e5);
|
||||||
|
c = hh(c, d, a, b, words[k + 15]!, 16, 0x1fa27cf8);
|
||||||
|
b = hh(b, c, d, a, words[k + 2]!, 23, 0xc4ac5665);
|
||||||
|
|
||||||
|
a = ii(a, b, c, d, words[k]!, 6, 0xf4292244);
|
||||||
|
d = ii(d, a, b, c, words[k + 7]!, 10, 0x432aff97);
|
||||||
|
c = ii(c, d, a, b, words[k + 14]!, 15, 0xab9423a7);
|
||||||
|
b = ii(b, c, d, a, words[k + 5]!, 21, 0xfc93a039);
|
||||||
|
a = ii(a, b, c, d, words[k + 12]!, 6, 0x655b59c3);
|
||||||
|
d = ii(d, a, b, c, words[k + 3]!, 10, 0x8f0ccc92);
|
||||||
|
c = ii(c, d, a, b, words[k + 10]!, 15, 0xffeff47d);
|
||||||
|
b = ii(b, c, d, a, words[k + 1]!, 21, 0x85845dd1);
|
||||||
|
a = ii(a, b, c, d, words[k + 8]!, 6, 0x6fa87e4f);
|
||||||
|
d = ii(d, a, b, c, words[k + 15]!, 10, 0xfe2ce6e0);
|
||||||
|
c = ii(c, d, a, b, words[k + 6]!, 15, 0xa3014314);
|
||||||
|
b = ii(b, c, d, a, words[k + 13]!, 21, 0x4e0811a1);
|
||||||
|
a = ii(a, b, c, d, words[k + 4]!, 6, 0xf7537e82);
|
||||||
|
d = ii(d, a, b, c, words[k + 11]!, 10, 0xbd3af235);
|
||||||
|
c = ii(c, d, a, b, words[k + 2]!, 15, 0x2ad7d2bb);
|
||||||
|
b = ii(b, c, d, a, words[k + 9]!, 21, 0xeb86d391);
|
||||||
|
|
||||||
|
a = addUnsigned(a, aa);
|
||||||
|
b = addUnsigned(b, bb);
|
||||||
|
c = addUnsigned(c, cc);
|
||||||
|
d = addUnsigned(d, dd);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (wordToHex(a) + wordToHex(b) + wordToHex(c) + wordToHex(d)).toLowerCase();
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
const WIRE_VARINT = 0;
|
||||||
|
const WIRE_BYTES = 2;
|
||||||
|
const WIRE_FIXED32 = 5;
|
||||||
|
|
||||||
|
export class ProtobufWriter {
|
||||||
|
private readonly buf: number[] = [];
|
||||||
|
|
||||||
|
private writeVarint(value: number): this {
|
||||||
|
let v = value >>> 0;
|
||||||
|
while (v >= 0x80) {
|
||||||
|
this.buf.push((v & 0x7f) | 0x80);
|
||||||
|
v >>>= 7;
|
||||||
|
}
|
||||||
|
this.buf.push(v & 0x7f);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeKey(fieldNumber: number, wireType: number): this {
|
||||||
|
return this.writeVarint((fieldNumber << 3) | wireType);
|
||||||
|
}
|
||||||
|
|
||||||
|
varint(fieldNumber: number, value: number): this {
|
||||||
|
return this.writeKey(fieldNumber, WIRE_VARINT).writeVarint(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fixed32(fieldNumber: number, value: number): this {
|
||||||
|
this.writeKey(fieldNumber, WIRE_FIXED32);
|
||||||
|
const v = value >>> 0;
|
||||||
|
this.buf.push(v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes(fieldNumber: number, data: ArrayLike<number>): this {
|
||||||
|
this.writeKey(fieldNumber, WIRE_BYTES).writeVarint(data.length);
|
||||||
|
for (let i = 0; i < data.length; i++) {
|
||||||
|
this.buf.push(data[i]! & 0xff);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
string(fieldNumber: number, value: string): this {
|
||||||
|
return this.bytes(fieldNumber, new TextEncoder().encode(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
message(fieldNumber: number, build: (writer: ProtobufWriter) => void): this {
|
||||||
|
const sub = new ProtobufWriter();
|
||||||
|
build(sub);
|
||||||
|
return this.bytes(fieldNumber, sub.toBytes());
|
||||||
|
}
|
||||||
|
|
||||||
|
toBytes(): Uint8Array<ArrayBuffer> {
|
||||||
|
return Uint8Array.from(this.buf);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { hash } from "./index.ts";
|
||||||
|
import { getArgus } from "./argus.ts";
|
||||||
|
import { getLadon } from "./ladon.ts";
|
||||||
|
import { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
|
||||||
|
|
||||||
|
export interface UnidbgHeaders extends Record<string, string> {
|
||||||
|
"x-argus": string;
|
||||||
|
"x-ladon": string;
|
||||||
|
"x-khronos": string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function generateHeaders(
|
||||||
|
rawQuery: string,
|
||||||
|
xssStub = "",
|
||||||
|
timestamp: number = Math.floor(Date.now() / 1000),
|
||||||
|
config: UnidbgConfig = defaultUnidbgConfig,
|
||||||
|
): Promise<UnidbgHeaders> {
|
||||||
|
const [argus, ladon] = await Promise.all([
|
||||||
|
getArgus(rawQuery, xssStub, timestamp, config),
|
||||||
|
getLadon(timestamp, config),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x-argus": argus,
|
||||||
|
"x-ladon": ladon,
|
||||||
|
"x-khronos": String(timestamp),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signRequest(
|
||||||
|
url: string,
|
||||||
|
body?: ArrayBuffer | Uint8Array<ArrayBuffer> | string,
|
||||||
|
config: UnidbgConfig = defaultUnidbgConfig,
|
||||||
|
): Promise<Record<string, string>> {
|
||||||
|
const rawQuery = new URL(url).search.replace(/^\?/, "");
|
||||||
|
|
||||||
|
const hasBody = typeof body === "string" ? body.length > 0 : (body?.byteLength ?? 0) > 0;
|
||||||
|
const xssStub = hasBody ? await hash.md5(body!) : "";
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const headers: Record<string, string> = await generateHeaders(
|
||||||
|
rawQuery,
|
||||||
|
xssStub,
|
||||||
|
Math.floor(now / 1000),
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
headers["x-ss-req-ticket"] = String(now);
|
||||||
|
if (hasBody) {
|
||||||
|
headers["X-SS-STUB"] = xssStub;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getArgus } from "./argus.ts";
|
||||||
|
export { getLadon } from "./ladon.ts";
|
||||||
|
export { defaultUnidbgConfig, type UnidbgConfig } from "./unidbgconfig.ts";
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const ROUNDS = 72;
|
||||||
|
const MASK64 = 0xffffffffffffffffn;
|
||||||
|
|
||||||
|
const Z4 = 0x3dc94c3a046d678bn;
|
||||||
|
|
||||||
|
function getBit(value: bigint, position: number): bigint {
|
||||||
|
return (value >> BigInt(position)) & 1n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateLeft64(v: bigint, n: bigint): bigint {
|
||||||
|
return ((v << n) | (v >> (64n - n))) & MASK64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateRight64(v: bigint, n: bigint): bigint {
|
||||||
|
return ((v << (64n - n)) | (v >> n)) & MASK64;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyExpansion(key: readonly [bigint, bigint, bigint, bigint]): bigint[] {
|
||||||
|
const k: bigint[] = [key[0] & MASK64, key[1] & MASK64, key[2] & MASK64, key[3] & MASK64];
|
||||||
|
|
||||||
|
for (let i = 4; i < ROUNDS; i++) {
|
||||||
|
let tmp = rotateRight64(k[i - 1]!, 3n);
|
||||||
|
tmp ^= k[i - 3]!;
|
||||||
|
tmp ^= rotateRight64(tmp, 1n);
|
||||||
|
k.push((~k[i - 4]! ^ tmp ^ getBit(Z4, (i - 4) % 62) ^ 3n) & MASK64);
|
||||||
|
}
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function simonEncrypt(
|
||||||
|
plaintext: readonly [bigint, bigint],
|
||||||
|
key: readonly [bigint, bigint, bigint, bigint],
|
||||||
|
): [bigint, bigint] {
|
||||||
|
const k = keyExpansion(key);
|
||||||
|
|
||||||
|
let x = plaintext[0] & MASK64;
|
||||||
|
let y = plaintext[1] & MASK64;
|
||||||
|
|
||||||
|
for (let i = 0; i < ROUNDS; i++) {
|
||||||
|
const tmp = y;
|
||||||
|
const f = rotateLeft64(y, 1n) & rotateLeft64(y, 8n);
|
||||||
|
y = (x ^ f ^ rotateLeft64(y, 2n) ^ k[i]!) & MASK64;
|
||||||
|
x = tmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js
|
||||||
|
|
||||||
|
type Bytes = ArrayLike<number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 循环左移
|
||||||
|
*/
|
||||||
|
function rotl(value: number, shift: number): number {
|
||||||
|
const normalizedShift = shift & 31;
|
||||||
|
return (value << normalizedShift) | (value >>> (32 - normalizedShift));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 二进制异或运算
|
||||||
|
*/
|
||||||
|
function xor(left: Bytes, right: Bytes): Uint8Array {
|
||||||
|
if (left.length !== right.length) {
|
||||||
|
throw new Error("XOR inputs must have the same length");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Uint8Array(left.length);
|
||||||
|
for (let i = 0; i < left.length; i++) {
|
||||||
|
result[i] = (left[i]! ^ right[i]!) & 0xff;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩函数中的置换函数 P0(X) = X xor (X <<< 9) xor (X <<< 17)
|
||||||
|
*/
|
||||||
|
function p0(value: number): number {
|
||||||
|
return (value ^ rotl(value, 9)) ^ rotl(value, 17);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息扩展中的置换函数 P1(X) = X xor (X <<< 15) xor (X <<< 23)
|
||||||
|
*/
|
||||||
|
function p1(value: number): number {
|
||||||
|
return (value ^ rotl(value, 15)) ^ rotl(value, 23);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 SM3 摘要。
|
||||||
|
*/
|
||||||
|
export function sm3(input: Bytes): Uint8Array<ArrayBuffer> {
|
||||||
|
const inputLength = input.length;
|
||||||
|
const paddedLength = Math.ceil((inputLength + 9) / 64) * 64;
|
||||||
|
const message = new Uint8Array(paddedLength);
|
||||||
|
message.set(input);
|
||||||
|
message[inputLength] = 0x80;
|
||||||
|
|
||||||
|
const bitLength = BigInt(inputLength) * 8n;
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
message[paddedLength - 1 - i] = Number((bitLength >> BigInt(i * 8)) & 0xffn);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataView = new DataView(message.buffer);
|
||||||
|
const state = new Uint32Array([
|
||||||
|
0x7380166f,
|
||||||
|
0x4914b2b9,
|
||||||
|
0x172442d7,
|
||||||
|
0xda8a0600,
|
||||||
|
0xa96f30bc,
|
||||||
|
0x163138aa,
|
||||||
|
0xe38dee4d,
|
||||||
|
0xb0fb0e4e,
|
||||||
|
]);
|
||||||
|
const words = new Uint32Array(68);
|
||||||
|
const expandedWords = new Uint32Array(64);
|
||||||
|
|
||||||
|
for (let block = 0; block < message.length / 64; block++) {
|
||||||
|
const start = block * 64;
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
words[i] = dataView.getUint32(start + i * 4, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 16; i < 68; i++) {
|
||||||
|
words[i] = (p1((words[i - 16]! ^ words[i - 9]!) ^ rotl(words[i - 3]!, 15))
|
||||||
|
^ rotl(words[i - 13]!, 7))
|
||||||
|
^ words[i - 6]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
expandedWords[i] = words[i]! ^ words[i + 4]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
let a = state[0]!;
|
||||||
|
let b = state[1]!;
|
||||||
|
let c = state[2]!;
|
||||||
|
let d = state[3]!;
|
||||||
|
let e = state[4]!;
|
||||||
|
let f = state[5]!;
|
||||||
|
let g = state[6]!;
|
||||||
|
let h = state[7]!;
|
||||||
|
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const t = i <= 15 ? 0x79cc4519 : 0x7a879d8a;
|
||||||
|
const ss1 = rotl(rotl(a, 12) + e + rotl(t, i), 7);
|
||||||
|
const ss2 = ss1 ^ rotl(a, 12);
|
||||||
|
const tt1 = (i <= 15 ? (a ^ b) ^ c : ((a & b) | (a & c)) | (b & c))
|
||||||
|
+ d + ss2 + expandedWords[i]!;
|
||||||
|
const tt2 = (i <= 15 ? (e ^ f) ^ g : (e & f) | (~e & g))
|
||||||
|
+ h + ss1 + words[i]!;
|
||||||
|
|
||||||
|
d = c;
|
||||||
|
c = rotl(b, 9);
|
||||||
|
b = a;
|
||||||
|
a = tt1;
|
||||||
|
h = g;
|
||||||
|
g = rotl(f, 19);
|
||||||
|
f = e;
|
||||||
|
e = p0(tt2);
|
||||||
|
}
|
||||||
|
|
||||||
|
state[0] = state[0]! ^ a;
|
||||||
|
state[1] = state[1]! ^ b;
|
||||||
|
state[2] = state[2]! ^ c;
|
||||||
|
state[3] = state[3]! ^ d;
|
||||||
|
state[4] = state[4]! ^ e;
|
||||||
|
state[5] = state[5]! ^ f;
|
||||||
|
state[6] = state[6]! ^ g;
|
||||||
|
state[7] = state[7]! ^ h;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = new Uint8Array(32);
|
||||||
|
for (let i = 0; i < state.length; i++) {
|
||||||
|
const word = state[i]!;
|
||||||
|
result[i * 4] = word >>> 24;
|
||||||
|
result[i * 4 + 1] = word >>> 16;
|
||||||
|
result[i * 4 + 2] = word >>> 8;
|
||||||
|
result[i * 4 + 3] = word;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 HMAC-SM3。
|
||||||
|
*/
|
||||||
|
export function hmac(input: Bytes, key: Bytes): Uint8Array<ArrayBuffer> {
|
||||||
|
const blockLength = 64;
|
||||||
|
const normalizedKey = key.length > blockLength ? sm3(key) : Uint8Array.from(key);
|
||||||
|
const paddedKey = new Uint8Array(blockLength);
|
||||||
|
paddedKey.set(normalizedKey);
|
||||||
|
|
||||||
|
const innerPad = new Uint8Array(blockLength).fill(0x36);
|
||||||
|
const outerPad = new Uint8Array(blockLength).fill(0x5c);
|
||||||
|
const innerKey = xor(paddedKey, innerPad);
|
||||||
|
const outerKey = xor(paddedKey, outerPad);
|
||||||
|
|
||||||
|
const innerMessage = new Uint8Array(blockLength + input.length);
|
||||||
|
innerMessage.set(innerKey);
|
||||||
|
innerMessage.set(input, blockLength);
|
||||||
|
|
||||||
|
const outerMessage = new Uint8Array(blockLength + 32);
|
||||||
|
outerMessage.set(outerKey);
|
||||||
|
outerMessage.set(sm3(innerMessage), blockLength);
|
||||||
|
return sm3(outerMessage);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { hash, pkcs7Pad } from "./index.ts";
|
||||||
|
|
||||||
|
const ROUNDS = 34;
|
||||||
|
const MASK64 = 0xffffffffffffffffn;
|
||||||
|
const WORD_SIZE = 64n;
|
||||||
|
const ALPHA = 8n;
|
||||||
|
const BETA = 3n;
|
||||||
|
|
||||||
|
function readUint64LE(view: DataView, offset: number): bigint {
|
||||||
|
return view.getBigUint64(offset, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keySchedule(key: Uint8Array): bigint[] {
|
||||||
|
const view = new DataView(key.buffer, key.byteOffset, key.byteLength);
|
||||||
|
|
||||||
|
const ks: bigint[] = [readUint64LE(view, 0) & MASK64];
|
||||||
|
const numWords = (key.length * 8) / Number(WORD_SIZE);
|
||||||
|
const ls: bigint[] = [];
|
||||||
|
for (let i = 1; i < numWords; i++) {
|
||||||
|
ls.push(readUint64LE(view, i * 8) & MASK64);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let x = 0; x < ROUNDS - 1; x++) {
|
||||||
|
const rsX = (((ls[x]! << (WORD_SIZE - ALPHA)) + (ls[x]! >> ALPHA)) & MASK64);
|
||||||
|
const addSxy = (rsX + ks[x]!) & MASK64;
|
||||||
|
const newX = BigInt(x) ^ addSxy;
|
||||||
|
const lsY = (((ks[x]! >> (WORD_SIZE - BETA)) + (ks[x]! << BETA)) & MASK64);
|
||||||
|
ls.push(newX);
|
||||||
|
ks.push(newX ^ lsY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ks;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function encryptBlock(ks: readonly bigint[], block: Uint8Array, out: Uint8Array, outOffset: number): void {
|
||||||
|
const view = new DataView(block.buffer, block.byteOffset, block.byteLength);
|
||||||
|
let y = readUint64LE(view, 0);
|
||||||
|
let x = readUint64LE(view, 8);
|
||||||
|
|
||||||
|
for (const k of ks) {
|
||||||
|
const rsX = (((x << (WORD_SIZE - ALPHA)) + (x >> ALPHA)) & MASK64);
|
||||||
|
const addSxy = (rsX + y) & MASK64;
|
||||||
|
x = k ^ addSxy;
|
||||||
|
const lsY = (((y >> (WORD_SIZE - BETA)) + (y << BETA)) & MASK64);
|
||||||
|
y = x ^ lsY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outView = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||||
|
outView.setBigUint64(outOffset, y & MASK64, true);
|
||||||
|
outView.setBigUint64(outOffset + 8, x & MASK64, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function speckEncrypt(key: Uint8Array, plaintext: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
|
if (key.length !== 32) {
|
||||||
|
throw new Error(`Speck key must be 32 bytes, got ${key.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const padded = pkcs7Pad(plaintext, 16);
|
||||||
|
const ks = keySchedule(key);
|
||||||
|
|
||||||
|
const out = new Uint8Array(padded.length);
|
||||||
|
for (let i = 0; i < padded.length; i += 16) {
|
||||||
|
encryptBlock(ks, padded.subarray(i, i + 16), out, i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function generateLadonKey(randomBytes: Uint8Array, aid: string): Promise<Uint8Array<ArrayBuffer>> {
|
||||||
|
const aidBytes = new TextEncoder().encode(aid);
|
||||||
|
const input = new Uint8Array(randomBytes.length + aidBytes.length);
|
||||||
|
input.set(randomBytes);
|
||||||
|
input.set(aidBytes, randomBytes.length);
|
||||||
|
|
||||||
|
const hex = await hash.md5(input);
|
||||||
|
return new TextEncoder().encode(hex);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// TT 加密(设备注册请求体):gzip + SHA512 校验 + AES-128-CBC,magic 头 "tc"
|
||||||
|
// 移植自 fanqie-assistant src/crypto/ttencrypt.ts(Node crypto 实现)
|
||||||
|
import { createCipheriv, createHash, randomBytes } from 'node:crypto'
|
||||||
|
import { gzipSync } from 'node:zlib'
|
||||||
|
|
||||||
|
// 固定盐(base64 常量,社区共知)
|
||||||
|
const FIXED_STRING = Buffer.from(
|
||||||
|
'TdTC5rgxYgkOUrPHpnM7pByyRiuCmrWKGWs521cXdST0m69/COjWjSanLjfBqVovHwWlGJKu8pSXMrYqOKrdWA==',
|
||||||
|
'base64',
|
||||||
|
)
|
||||||
|
|
||||||
|
const sha512 = (data: Buffer): Buffer => createHash('sha512').update(data).digest()
|
||||||
|
|
||||||
|
export const encryptTT = (data: Buffer): Buffer => {
|
||||||
|
const rand = randomBytes(32)
|
||||||
|
const hashValue = sha512(Buffer.concat([sha512(rand), FIXED_STRING]))
|
||||||
|
const key = hashValue.subarray(0, 16)
|
||||||
|
const iv = hashValue.subarray(16, 32)
|
||||||
|
const compressed = gzipSync(data)
|
||||||
|
const payload = Buffer.concat([sha512(compressed), compressed])
|
||||||
|
const cipher = createCipheriv('aes-128-cbc', key, iv)
|
||||||
|
const encrypted = Buffer.concat([cipher.update(payload), cipher.final()])
|
||||||
|
return Buffer.concat([Buffer.from([116, 99, 5, 16, 0, 0]), rand, encrypted])
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { encryptTT }
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// 汽水音乐(luna, aid=386088)签名配置
|
||||||
|
// signKey/licenseId 是字节 metasec SDK 家族(v04.04.05-ov-android)共用密钥——经 ssovit(TikTok
|
||||||
|
// aid=1233)/fanqie-assistant(番茄 aid=1967)/本项目(汽水 aid=386088) 三方同源实证;per-app 的只有
|
||||||
|
// 载荷里的 msAppID(argus protobuf field 4,由 config.aid 提供)。metasec license blob(native
|
||||||
|
// SDK 运行时 init 用)不参与离线签名。
|
||||||
|
import { unhex } from "./index.ts";
|
||||||
|
|
||||||
|
export interface UnidbgConfig {
|
||||||
|
signKey: Uint8Array;
|
||||||
|
aid: string;
|
||||||
|
licenseId: string;
|
||||||
|
sdkVersion: string;
|
||||||
|
sdkVersionInt: number;
|
||||||
|
callType: number;
|
||||||
|
secDeviceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultUnidbgConfig: UnidbgConfig = {
|
||||||
|
signKey: new Uint8Array(
|
||||||
|
unhex("ac1adaae95a7af94a5114ab3b3a97dd80050aa0a39314c40528caec95256c28c"),
|
||||||
|
),
|
||||||
|
aid: "386088",
|
||||||
|
licenseId: "1611921764",
|
||||||
|
sdkVersion: "v04.04.05-ov-android",
|
||||||
|
sdkVersionInt: 134744640,
|
||||||
|
callType: 738,
|
||||||
|
};
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// 汽水音乐设备注册 —— i.snssdk.com 统一设备服务(按 body 里的 aid 分 app)
|
||||||
|
// 接口: POST https://i.snssdk.com/service/2/device_register/?tt_data=a(TT 加密 body,免签名)
|
||||||
|
// 实测(2026-10-01)aid=386088 可注册成功,返回 device_id/install_id/device_token。
|
||||||
|
// 设备身份持久化于 data/auth.json 的 _qs_device 字段(复用注册=同一台"设备",避免重复注册)
|
||||||
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
import { getCredentials, setCredentials } from '../auth/store.ts'
|
||||||
|
import { encryptTT } from './crypto/ttencrypt.ts'
|
||||||
|
|
||||||
|
const REGISTER_URL = 'https://i.snssdk.com/service/2/device_register/?tt_data=a'
|
||||||
|
|
||||||
|
// 与签名查询参数一致的设备画像(注册后不再变化)
|
||||||
|
const DEVICE_FINGERPRINT = {
|
||||||
|
device_type: 'ABR-AL80',
|
||||||
|
device_brand: 'HUAWEI',
|
||||||
|
device_model: 'ABR-AL80',
|
||||||
|
os_version: '15',
|
||||||
|
os_api: 35,
|
||||||
|
resolution: '1080*1920',
|
||||||
|
dpi: 480,
|
||||||
|
channel: 'xiaomi_8478_64',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANDROID_VERSIONS = ['10', '11', '12', '13', '14']
|
||||||
|
const HEX_LOW = '0123456789abcdef'
|
||||||
|
const pick = (list: readonly string[]) => list[Math.floor(Math.random() * list.length)]
|
||||||
|
const randomHex = (k: number) => Array.from({ length: k }, () => HEX_LOW[Math.floor(Math.random() * 16)]).join('')
|
||||||
|
const uuid = () => crypto.randomUUID()
|
||||||
|
|
||||||
|
export interface QsDevice {
|
||||||
|
device_id: string
|
||||||
|
install_id: string
|
||||||
|
device_token?: string
|
||||||
|
device_type: string
|
||||||
|
device_brand: string
|
||||||
|
device_model: string
|
||||||
|
os_version: string
|
||||||
|
channel: string
|
||||||
|
openudid: string
|
||||||
|
cdid: string
|
||||||
|
registeredAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildRequestBody = () => {
|
||||||
|
const osVersion = pick(ANDROID_VERSIONS)
|
||||||
|
const genTime = Date.now()
|
||||||
|
return {
|
||||||
|
magic_tag: 'ss_app_log',
|
||||||
|
header: {
|
||||||
|
display_name: '汽水音乐',
|
||||||
|
aid: 386088,
|
||||||
|
channel: DEVICE_FINGERPRINT.channel,
|
||||||
|
package: 'com.luna.music',
|
||||||
|
sdk_version: '3.7.0-rc.25-luna-music',
|
||||||
|
sdk_target_version: 29,
|
||||||
|
git_hash: '711d1a7',
|
||||||
|
density_dpi: DEVICE_FINGERPRINT.dpi,
|
||||||
|
display_density: 'xhdpi',
|
||||||
|
resolution: DEVICE_FINGERPRINT.resolution,
|
||||||
|
language: 'zh',
|
||||||
|
timezone: 8,
|
||||||
|
access: 'wifi',
|
||||||
|
not_request_sender: 0,
|
||||||
|
carrier: 'CHINA MOBILE',
|
||||||
|
mcc_mnc: '46000',
|
||||||
|
region: 'CN',
|
||||||
|
tz_name: 'Asia/Shanghai',
|
||||||
|
tz_offset: 28800,
|
||||||
|
sim_region: 'cn',
|
||||||
|
sim_serial_number: [],
|
||||||
|
oaid_may_support: false,
|
||||||
|
device_platform: 'android',
|
||||||
|
custom: { host_bit: 32 },
|
||||||
|
pre_installed_channel: '',
|
||||||
|
is_system_app: 0,
|
||||||
|
sdk_flavor: 'china',
|
||||||
|
guest_mode: 0,
|
||||||
|
os: 'Android',
|
||||||
|
os_version: osVersion,
|
||||||
|
os_api: DEVICE_FINGERPRINT.os_api,
|
||||||
|
device_model: DEVICE_FINGERPRINT.device_model,
|
||||||
|
device_brand: DEVICE_FINGERPRINT.device_brand,
|
||||||
|
device_manufacturer: DEVICE_FINGERPRINT.device_brand,
|
||||||
|
cpu_abi: 'arm64-v8a',
|
||||||
|
release_build: randomHex(7),
|
||||||
|
cdid: uuid(),
|
||||||
|
sig_hash: 'a4a27c2633195374c15651ffc3c4a497',
|
||||||
|
openudid: randomHex(20),
|
||||||
|
clientudid: uuid(),
|
||||||
|
req_id: uuid(),
|
||||||
|
rom: randomHex(14).toUpperCase(),
|
||||||
|
apk_first_install_time: genTime - Math.floor(Math.random() * 365) * 86_400_000,
|
||||||
|
ipv6_list: [
|
||||||
|
{ type: 'client_anpi', value: '::1' },
|
||||||
|
{ type: 'client_anpi', value: 'fd9d:8e11:8c31:2a2c:0:0:0:1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
_gen_time: genTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册新设备并持久化 */
|
||||||
|
export const registerDevice = async (): Promise<QsDevice> => {
|
||||||
|
const body = buildRequestBody()
|
||||||
|
const encrypted = encryptTT(Buffer.from(JSON.stringify(body)))
|
||||||
|
const request = httpFetch(REGISTER_URL, {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'okhttp/4.10.0',
|
||||||
|
'Content-Type': 'application/octet-stream; tt-data=a',
|
||||||
|
},
|
||||||
|
body: encrypted,
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
const { statusCode, body: resBody } = await request.promise
|
||||||
|
const data = resBody as any
|
||||||
|
if (statusCode != 200 || !data?.device_id_str || !data?.install_id_str) {
|
||||||
|
throw new Error(`设备注册失败: HTTP ${statusCode} ${JSON.stringify(data).slice(0, 120)}`)
|
||||||
|
}
|
||||||
|
const device: QsDevice = {
|
||||||
|
device_id: String(data.device_id_str),
|
||||||
|
install_id: String(data.install_id_str),
|
||||||
|
device_token: data.device_token ? String(data.device_token) : '',
|
||||||
|
device_type: DEVICE_FINGERPRINT.device_type,
|
||||||
|
device_brand: DEVICE_FINGERPRINT.device_brand,
|
||||||
|
device_model: DEVICE_FINGERPRINT.device_model,
|
||||||
|
os_version: DEVICE_FINGERPRINT.os_version,
|
||||||
|
channel: DEVICE_FINGERPRINT.channel,
|
||||||
|
openudid: body.header.openudid,
|
||||||
|
cdid: body.header.cdid,
|
||||||
|
}
|
||||||
|
setCredentials('_qs_device', device as any)
|
||||||
|
console.log(`[qs/device] 设备注册成功: device_id=${device.device_id}`)
|
||||||
|
return device
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedDevice: QsDevice | null = null
|
||||||
|
|
||||||
|
/** 取当前设备(无则注册并持久化) */
|
||||||
|
export const getDevice = async (): Promise<QsDevice> => {
|
||||||
|
if (cachedDevice) return cachedDevice
|
||||||
|
const stored = getCredentials('_qs_device') as QsDevice | null
|
||||||
|
if (stored?.device_id && stored?.install_id) {
|
||||||
|
cachedDevice = stored
|
||||||
|
return cachedDevice
|
||||||
|
}
|
||||||
|
cachedDevice = await registerDevice()
|
||||||
|
return cachedDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备是否已注册(不触发注册) */
|
||||||
|
export const hasDevice = (): boolean =>
|
||||||
|
Boolean(cachedDevice || (getCredentials('_qs_device') as QsDevice | null)?.device_id)
|
||||||
|
|
||||||
|
export default { registerDevice, getDevice, hasDevice }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// 汽水音乐(字节跳动)平台模块
|
||||||
|
// 匿名可用面(2026-10-01 实测):搜索 / 曲目信息 / 歌词 / 封面 / 30 秒试听取流(未加密直链)。
|
||||||
|
// 登录态(/login/qs/qr 抖音扫码):设备注册 + X-Argus/X-Ladon 自签走 /luna/pc/track_v2,
|
||||||
|
// 加密音频服务端解密后经 /stream/qs/:id 代理回明文流(完整曲目,2026-10-01 全链路实测)
|
||||||
|
import qsApi, { getStreamBuffer } from '../apis/qs.ts'
|
||||||
|
import getLyric from './lyric.ts'
|
||||||
|
import getMusicInfo, { coverUrl } from './musicInfo.ts'
|
||||||
|
import musicSearch from './musicSearch.ts'
|
||||||
|
|
||||||
|
const qs = {
|
||||||
|
musicSearch,
|
||||||
|
getMusicUrl(songInfo, type) {
|
||||||
|
return qsApi.getMusicUrl(songInfo, type)
|
||||||
|
},
|
||||||
|
// 明文音频 Buffer(/stream/qs/:id 代理端点消费)
|
||||||
|
getStreamBuffer(trackId: string) {
|
||||||
|
return getStreamBuffer(trackId)
|
||||||
|
},
|
||||||
|
getLyric(songInfo) {
|
||||||
|
return getLyric(songInfo.songmid)
|
||||||
|
},
|
||||||
|
getPic(songInfo) {
|
||||||
|
return getMusicInfo(songInfo.songmid).promise.then(body => coverUrl(body?.seo_track?.track?.album))
|
||||||
|
},
|
||||||
|
getMusicDetailPageUrl(songInfo) {
|
||||||
|
return `https://music.douyin.com/qishui/share/track?track_id=${songInfo.songmid}`
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default qs
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// 汽水音乐歌词(逐字时间轴 → LRC/YRC)
|
||||||
|
// 来源: seo_track 的 lyric.content,行格式 `[起(ms),长(ms)]<行内偏移(ms),长(ms),0>字...`
|
||||||
|
// 与网易云 yrc 同族(wy/lyric.ts 的 parseTools 处理 (a,b,c) 括号变体,这里是尖括号)
|
||||||
|
import musicInfo from './musicInfo.ts'
|
||||||
|
|
||||||
|
const lineRe = /^\[(\d+),\d+\]/
|
||||||
|
const wordRe = /<(\d+),(\d+),\d+>([^<]*)/g
|
||||||
|
|
||||||
|
const msFormat = (timeMs: number) => {
|
||||||
|
if (Number.isNaN(timeMs)) return ''
|
||||||
|
let ms = timeMs % 1000
|
||||||
|
timeMs /= 1000
|
||||||
|
const m = parseInt(String(timeMs / 60)).toString().padStart(2, '0')
|
||||||
|
timeMs %= 60
|
||||||
|
const s = parseInt(String(timeMs)).toString().padStart(2, '0')
|
||||||
|
return `[${m}:${s}.${ms}]`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseTimedLyric = (content: string) => {
|
||||||
|
const lrcLines: string[] = []
|
||||||
|
const yrcLines: string[] = []
|
||||||
|
for (const rawLine of String(content || '').split('\n')) {
|
||||||
|
const line = rawLine.trim()
|
||||||
|
const lineMatch = lineRe.exec(line)
|
||||||
|
if (!lineMatch) continue
|
||||||
|
const timeTag = msFormat(parseInt(lineMatch[1]))
|
||||||
|
if (!timeTag) continue
|
||||||
|
const words = [...line.slice(lineMatch[0].length).matchAll(wordRe)]
|
||||||
|
if (!words.length) continue
|
||||||
|
const text = words.map(word => word[3]).join('')
|
||||||
|
if (!text.trim()) continue // 间奏空行
|
||||||
|
lrcLines.push(`${timeTag}${text}`)
|
||||||
|
yrcLines.push(`${timeTag}${words.map(word => `<${word[1]},${word[2]}>${word[3]}`).join('')}`)
|
||||||
|
}
|
||||||
|
return { lyric: lrcLines.join('\n'), yrc: yrcLines.join('\n') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default (trackId: string) => {
|
||||||
|
const requestObj = musicInfo(trackId)
|
||||||
|
requestObj.promise = requestObj.promise.then(body => {
|
||||||
|
const info = parseTimedLyric(body?.lyric?.content)
|
||||||
|
if (!info.lyric) return Promise.reject(new Error('获取歌词失败'))
|
||||||
|
return { lyric: info.lyric, tlyric: '', yrc: info.yrc }
|
||||||
|
}) as any
|
||||||
|
return requestObj
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// 汽水音乐曲目信息(H5 SEO 通道)
|
||||||
|
// 接口: https://beta-luna.douyin.com/luna/h5/seo_track?track_id=<id>&device_platform=web
|
||||||
|
// 匿名可访问(无签名要求):返回曲目元数据(name/artists/album/duration/bit_rates/preview)、
|
||||||
|
// 逐字歌词(lyric.content)与试听播放信息(track_player.url_player_info / video_model)
|
||||||
|
// 注意:seo_track 属分享页/SEO 数据面,track_player 里只有 30 秒试听资源;
|
||||||
|
// 完整音频需真机凭证走 /luna/pc/track_v2,且音频经 CENC 加密,见 apis/qs.ts 头注释
|
||||||
|
import { httpFetch } from '../request.ts'
|
||||||
|
import type { RequestObj } from '../types.ts'
|
||||||
|
|
||||||
|
export default (trackId: string): RequestObj<any> => {
|
||||||
|
const requestObj = httpFetch(`https://beta-luna.douyin.com/luna/h5/seo_track?track_id=${trackId}&device_platform=web`, {
|
||||||
|
method: 'get',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 10) AppleWebKit/537.36',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
requestObj.promise = requestObj.promise.then(({ statusCode, body }) => {
|
||||||
|
if (statusCode != 200 || !body?.seo_track?.track) return Promise.reject(new Error('获取歌曲信息失败'))
|
||||||
|
return body
|
||||||
|
})
|
||||||
|
return requestObj
|
||||||
|
}
|
||||||
|
// 专辑封面 URL:urls[0] 为 CDN 前缀,uri 为资源路径,拼尺寸模板得方形封面(社区通用做法)
|
||||||
|
export const coverUrl = (album: any): string | null => {
|
||||||
|
const urlCover = album?.url_cover
|
||||||
|
if (!urlCover?.uri) return null
|
||||||
|
const prefix = Array.isArray(urlCover.urls) && urlCover.urls.length ? urlCover.urls[0] : 'https://p3-luna.douyinpic.com/img/'
|
||||||
|
return `${prefix}${urlCover.uri}~c5_500x500.jpg`
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// 汽水音乐搜索(App 搜索接口直连)
|
||||||
|
// 接口: https://api.qishui.com/luna/search/track?<安卓设备参数>&q=<关键词>&cursor=&count=
|
||||||
|
// 实测(2026-10-01)匿名可搜索;关键在安卓 App 的设备参数指纹——
|
||||||
|
// 缺参数或用 PC 端参数(device_platform=windows)上游会静默风控,返回 200 + 空 body
|
||||||
|
import { sizeFormate, formatPlayTime } from '../index.ts'
|
||||||
|
import { httpFetch } from '../request.ts'
|
||||||
|
import { coverUrl } from './musicInfo.ts'
|
||||||
|
|
||||||
|
// 安卓 App 设备参数指纹(社区验证可用的静态设备 id,与 musicdl 一致;被风控时需换新)
|
||||||
|
const SEARCH_DEVICE_PARAMS = {
|
||||||
|
device_platform: 'android',
|
||||||
|
os: 'android',
|
||||||
|
ssmix: 'a',
|
||||||
|
cdid: '46556f98-1720-4248-83da-62b74b60b46a',
|
||||||
|
channel: 'xiaomi_8478_64',
|
||||||
|
aid: '386088',
|
||||||
|
app_name: 'luna',
|
||||||
|
version_code: '100198030',
|
||||||
|
version_name: '19.8.0',
|
||||||
|
manifest_version_code: '100198030',
|
||||||
|
update_version_code: '100198030',
|
||||||
|
resolution: '1080*1920',
|
||||||
|
dpi: '480',
|
||||||
|
device_type: 'ABR-AL80',
|
||||||
|
device_brand: 'HUAWEI',
|
||||||
|
language: 'zh',
|
||||||
|
os_api: '35',
|
||||||
|
os_version: '15',
|
||||||
|
ac: 'wifi',
|
||||||
|
device_model: 'ABR-AL80',
|
||||||
|
save_power: '0',
|
||||||
|
font_size: '1.00',
|
||||||
|
luna_first_launch_apk_type: 'normal_apk',
|
||||||
|
diversion_channel_name: 'xiaomi_8478_64',
|
||||||
|
is_car_play: '0',
|
||||||
|
battery: '0.99',
|
||||||
|
network_speed: '10156',
|
||||||
|
hybrid_version_code: '100198030',
|
||||||
|
tz_name: 'Asia/Shanghai',
|
||||||
|
tz_offset: '28800',
|
||||||
|
luna_register_time: '1784311292',
|
||||||
|
package: 'com.luna.music',
|
||||||
|
charge: '0',
|
||||||
|
luna_apk_type: 'normal_apk',
|
||||||
|
output_device_type: 'Phone',
|
||||||
|
volume: '1.00',
|
||||||
|
brightness: '0.08',
|
||||||
|
need_personal_recommend: '1',
|
||||||
|
is_teen_mode: '0',
|
||||||
|
sim_region: 'cn',
|
||||||
|
android_device_type: 'default',
|
||||||
|
iid: '2204957404569386',
|
||||||
|
device_id: '2204957404565290',
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_UA = 'Luna/19.8.0 (com.luna.music/100198030);Android 15;ABR-AL80'
|
||||||
|
|
||||||
|
const buildSearchUrl = (str: string, cursor: number, count: number) => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...SEARCH_DEVICE_PARAMS,
|
||||||
|
_rticket: String(Date.now()),
|
||||||
|
q: str,
|
||||||
|
cursor: String(cursor),
|
||||||
|
count: String(count),
|
||||||
|
})
|
||||||
|
return `https://api.qishui.com/luna/search/track?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
limit: 30,
|
||||||
|
total: 0,
|
||||||
|
page: 0,
|
||||||
|
allPage: 1,
|
||||||
|
getSinger(artists) {
|
||||||
|
return (artists || []).map(singer => singer.name).filter(Boolean).join('、')
|
||||||
|
},
|
||||||
|
handleResult(rawList) {
|
||||||
|
if (!rawList) return []
|
||||||
|
return rawList.map(item => {
|
||||||
|
const track = item?.entity?.track
|
||||||
|
if (!track?.id) return null
|
||||||
|
// types 反映匿名实际可获取的资源:30 秒试听(preview.bit_rates 为试听码率)
|
||||||
|
// track.bit_rates 是完整曲目码率元数据(66k~hi_res),但匿名无权取流,不对外声明
|
||||||
|
const previewSize = track.preview?.bit_rates?.[0]?.size
|
||||||
|
const size = previewSize ? sizeFormate(previewSize) : null
|
||||||
|
const types = [{ type: '128k', size }]
|
||||||
|
const _types = { '128k': { size } }
|
||||||
|
return {
|
||||||
|
singer: this.getSinger(track.artists),
|
||||||
|
name: track.name,
|
||||||
|
albumName: track.album?.name ?? null,
|
||||||
|
albumId: track.album?.id ?? null,
|
||||||
|
source: 'qs',
|
||||||
|
interval: formatPlayTime(track.duration / 1000),
|
||||||
|
songmid: String(track.id),
|
||||||
|
img: coverUrl(track.album),
|
||||||
|
lrc: null,
|
||||||
|
types,
|
||||||
|
_types,
|
||||||
|
typeUrl: {},
|
||||||
|
}
|
||||||
|
}).filter(Boolean)
|
||||||
|
},
|
||||||
|
search(str, page = 1, limit, retryNum = 0) {
|
||||||
|
if (++retryNum > 3) return Promise.reject(new Error('try max num'))
|
||||||
|
if (limit == null) limit = this.limit
|
||||||
|
const cursor = limit * (page - 1)
|
||||||
|
const searchRequest = httpFetch(buildSearchUrl(str, cursor, limit), {
|
||||||
|
method: 'get',
|
||||||
|
headers: { 'User-Agent': SEARCH_UA },
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
return searchRequest.promise.then(({ body }) => {
|
||||||
|
// 被风控/无结果时上游不返回 result_groups(200 + {"extra":{"empty_search":1}}),重试
|
||||||
|
if (!body?.result_groups) return this.search(str, page, limit, retryNum)
|
||||||
|
const group = body.result_groups.find(g => g?.data?.length) || { data: [], has_more: false }
|
||||||
|
const list = this.handleResult(group.data)
|
||||||
|
const total = group.has_more ? cursor + list.length + 1 : cursor + list.length
|
||||||
|
this.total = total
|
||||||
|
this.page = page
|
||||||
|
this.allPage = group.has_more ? page + 1 : page
|
||||||
|
return {
|
||||||
|
list,
|
||||||
|
allPage: this.allPage,
|
||||||
|
limit: this.limit,
|
||||||
|
total,
|
||||||
|
source: 'qs',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// 汽水音乐扫码登录 —— 抖音 passport web 二维码(须用「抖音 APP」扫码,汽水 App 扫可能不完成确认)
|
||||||
|
// 接口:
|
||||||
|
// GET https://api.qishui.com/passport/web/get_qrcode/ → token + qrcode 图片 + 初始化 Cookie
|
||||||
|
// POST https://api.qishui.com/passport/web/check_qrconnect/ → 轮询状态,确认后 Set-Cookie 带 sessionid
|
||||||
|
// 状态映射沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存)
|
||||||
|
import { httpFetch } from '../common/request.ts'
|
||||||
|
import { getCredentials, setCredentials } from '../auth/store.ts'
|
||||||
|
|
||||||
|
const PASSPORT_HOST = 'https://api.qishui.com'
|
||||||
|
|
||||||
|
const PASSPORT_FIXED = {
|
||||||
|
aid: '386088',
|
||||||
|
passport_jssdk_version: '2.4.13',
|
||||||
|
passport_jssdk_type: 'normal',
|
||||||
|
is_from_ttaccountsdk: '1',
|
||||||
|
next: 'https://api.qishui.com',
|
||||||
|
need_logo: 'false',
|
||||||
|
need_short_url: 'false',
|
||||||
|
is_frontier: 'true',
|
||||||
|
is_new_login: '1',
|
||||||
|
iid: '27960026095955',
|
||||||
|
version_code: '30020100',
|
||||||
|
}
|
||||||
|
|
||||||
|
const webHeaders = (cookie = '') => ({
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36',
|
||||||
|
...(cookie ? { Cookie: cookie } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cookieValues = (headers: Record<string, any>): string[] => {
|
||||||
|
const raw = headers?.['set-cookie']
|
||||||
|
return Array.isArray(raw) ? raw : raw ? [raw] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookiePairsFrom = (headers: Record<string, any>): string[] =>
|
||||||
|
cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean)
|
||||||
|
|
||||||
|
const sessionIdFrom = (headers: Record<string, any>): string => {
|
||||||
|
for (const value of cookieValues(headers)) {
|
||||||
|
const match = String(value).match(/(?:^|[;,]\s*)sessionid=([^;,\s]+)/i)
|
||||||
|
if (match) return match[1]
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// get_qrcode 的 Set-Cookie 需在轮询时原样带上(内存暂存,随二维码生命周期)
|
||||||
|
const qrCookies = new Map<string, string>()
|
||||||
|
|
||||||
|
/** 创建扫码登录二维码 */
|
||||||
|
export const createQr = async (): Promise<{ token: string; qrimg: string; expireTime: number; scanApp: string }> => {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
passport_jssdk_version: PASSPORT_FIXED.passport_jssdk_version,
|
||||||
|
passport_jssdk_type: PASSPORT_FIXED.passport_jssdk_type,
|
||||||
|
is_from_ttaccountsdk: PASSPORT_FIXED.is_from_ttaccountsdk,
|
||||||
|
aid: PASSPORT_FIXED.aid,
|
||||||
|
next: PASSPORT_FIXED.next,
|
||||||
|
need_logo: PASSPORT_FIXED.need_logo,
|
||||||
|
need_short_url: PASSPORT_FIXED.need_short_url,
|
||||||
|
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||||
|
})
|
||||||
|
const request = httpFetch(`${PASSPORT_HOST}/passport/web/get_qrcode/?${query}`, {
|
||||||
|
method: 'get',
|
||||||
|
headers: webHeaders(),
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
const { statusCode, headers, body } = await request.promise
|
||||||
|
const data = (body as any)?.data ?? {}
|
||||||
|
if (statusCode != 200 || !data.token) {
|
||||||
|
throw new Error(`获取二维码失败: HTTP ${statusCode} ${JSON.stringify(body).slice(0, 120)}`)
|
||||||
|
}
|
||||||
|
qrCookies.set(data.token, cookiePairsFrom(headers).join('; '))
|
||||||
|
|
||||||
|
// qrcode 可能是 data URL,也可能是图片链接——后者取回转 data URL 方便直接展示
|
||||||
|
let qrimg = String(data.qrcode || '')
|
||||||
|
if (qrimg && !qrimg.startsWith('data:')) {
|
||||||
|
try {
|
||||||
|
const imgRes = await httpFetch(qrimg, { method: 'get', timeout: 15000 }).promise
|
||||||
|
qrimg = `data:image/png;base64,${imgRes.raw.toString('base64')}`
|
||||||
|
} catch {
|
||||||
|
// 取图失败时退回原始链接,由调用方处理
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
token: String(data.token),
|
||||||
|
qrimg,
|
||||||
|
expireTime: Number(data.expire_time) || 0,
|
||||||
|
scanApp: String(data.copywriting || '').includes('抖音') ? 'douyin' : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 轮询扫码状态
|
||||||
|
* @returns {{ code: 800|801|802|803, message: string, status?: string, errorCode?: number, nickname? }}
|
||||||
|
*/
|
||||||
|
export const checkQr = async (token: string): Promise<Record<string, any>> => {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
passport_jssdk_version: PASSPORT_FIXED.passport_jssdk_version,
|
||||||
|
passport_jssdk_type: PASSPORT_FIXED.passport_jssdk_type,
|
||||||
|
is_from_ttaccountsdk: PASSPORT_FIXED.is_from_ttaccountsdk,
|
||||||
|
aid: PASSPORT_FIXED.aid,
|
||||||
|
iid: PASSPORT_FIXED.iid,
|
||||||
|
})
|
||||||
|
const form = new URLSearchParams({
|
||||||
|
need_logo: PASSPORT_FIXED.need_logo,
|
||||||
|
need_short_url: PASSPORT_FIXED.need_short_url,
|
||||||
|
is_frontier: PASSPORT_FIXED.is_frontier,
|
||||||
|
token,
|
||||||
|
is_new_login: PASSPORT_FIXED.is_new_login,
|
||||||
|
next: PASSPORT_FIXED.next,
|
||||||
|
})
|
||||||
|
const cookie = qrCookies.get(token) || ''
|
||||||
|
const request = httpFetch(`${PASSPORT_HOST}/passport/web/check_qrconnect/?${query}`, {
|
||||||
|
method: 'post',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...webHeaders(cookie),
|
||||||
|
},
|
||||||
|
body: form.toString(),
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
const { statusCode, headers, body } = await request.promise
|
||||||
|
if (statusCode != 200) throw Object.assign(new Error(`轮询扫码状态失败: HTTP ${statusCode}`), { status: 502 })
|
||||||
|
const data = (body as any)?.data ?? {}
|
||||||
|
const sessionid = sessionIdFrom(headers)
|
||||||
|
const status = String(data.status ?? '')
|
||||||
|
const errorCode = Number(data.error_code) || 0
|
||||||
|
|
||||||
|
if (sessionid) {
|
||||||
|
setCredentials('qs', { sessionid })
|
||||||
|
qrCookies.delete(token)
|
||||||
|
const profile = (body as any)?.data?.user_info ?? {}
|
||||||
|
return {
|
||||||
|
code: 803,
|
||||||
|
message: '登录成功',
|
||||||
|
nickname: profile.nickname ?? profile.name ?? undefined,
|
||||||
|
status,
|
||||||
|
errorCode,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未确认:区分等待/已扫码/过期。上游状态语义未完全公开,按观察到的值映射,未知值一律视为等待
|
||||||
|
const expired = errorCode === 4 || /expire|过期|失效/i.test(String(data.description ?? ''))
|
||||||
|
if (expired) {
|
||||||
|
qrCookies.delete(token)
|
||||||
|
return { code: 800, message: '二维码已失效', status, errorCode }
|
||||||
|
}
|
||||||
|
if (/scan/i.test(status) || errorCode === 205) {
|
||||||
|
return { code: 802, message: '已扫码,待确认', status, errorCode }
|
||||||
|
}
|
||||||
|
return { code: 801, message: '等待扫码', status, errorCode }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前登录态 */
|
||||||
|
export const status = (): { logged: boolean; updatedAt?: string } => {
|
||||||
|
const cred = getCredentials('qs') as { sessionid?: string; updatedAt?: string } | null
|
||||||
|
return cred?.sessionid ? { logged: true, updatedAt: cred.updatedAt } : { logged: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 登录态 sessionid(未登录返回空串) */
|
||||||
|
export const getSessionId = (): string => (getCredentials('qs') as any)?.sessionid || ''
|
||||||
|
|
||||||
|
export default { createQr, checkQr, status, getSessionId }
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
// 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。
|
// 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。
|
||||||
|
|
||||||
/** 音乐平台标识 */
|
/** 音乐平台标识 */
|
||||||
export type Source = 'kw' | 'kg' | 'tx' | 'wy'
|
export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs'
|
||||||
|
|
||||||
/** 音质标识(wav 仅部分接口声明使用) */
|
/** 音质标识(wav 仅部分接口声明使用) */
|
||||||
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
|
export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import * as wyAuth from '../sdk/auth/wy.ts'
|
import * as wyAuth from '../sdk/auth/wy.ts'
|
||||||
import * as kgAuth from '../sdk/auth/kg.ts'
|
import * as kgAuth from '../sdk/auth/kg.ts'
|
||||||
import * as txAuth from '../sdk/auth/tx.ts'
|
import * as txAuth from '../sdk/auth/tx.ts'
|
||||||
|
import * as qsPassport from '../sdk/qs/passport.ts'
|
||||||
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
|
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
|
||||||
import { refreshAll } from './refresh.ts'
|
import { refreshAll } from './refresh.ts'
|
||||||
|
|
||||||
@@ -226,6 +227,45 @@ export const loginRoutes = async(fastify) => {
|
|||||||
return { logged: false }
|
return { logged: false }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------- 汽水音乐 ----------
|
||||||
|
|
||||||
|
fastify.post('/login/qs/qr', {
|
||||||
|
schema: {
|
||||||
|
summary: '获取汽水音乐扫码登录二维码',
|
||||||
|
description: '用「抖音 APP」扫码(汽水 App 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' +
|
||||||
|
'返回 qrimg(data URL)可直接展示,token 需原样回传。登录后 qs 取流走签名 track_v2,解锁完整曲目。',
|
||||||
|
tags: ['登录'],
|
||||||
|
},
|
||||||
|
}, async(_request, reply) => {
|
||||||
|
try {
|
||||||
|
return await qsPassport.createQr()
|
||||||
|
} catch (err) {
|
||||||
|
return fail(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.post('/login/qs/qr/check', {
|
||||||
|
schema: {
|
||||||
|
summary: '轮询汽水音乐扫码状态',
|
||||||
|
description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据)。',
|
||||||
|
tags: ['登录'],
|
||||||
|
body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] },
|
||||||
|
},
|
||||||
|
}, async(request, reply) => {
|
||||||
|
try {
|
||||||
|
return await qsPassport.checkQr(request.body.token)
|
||||||
|
} catch (err) {
|
||||||
|
return fail(reply, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.delete('/login/qs', {
|
||||||
|
schema: { summary: '退出汽水音乐登录', tags: ['登录'] },
|
||||||
|
}, async() => {
|
||||||
|
clearCredentials('qs')
|
||||||
|
return { logged: false }
|
||||||
|
})
|
||||||
|
|
||||||
// ---------- 通用 ----------
|
// ---------- 通用 ----------
|
||||||
|
|
||||||
fastify.post('/login/refresh', {
|
fastify.post('/login/refresh', {
|
||||||
@@ -252,6 +292,7 @@ export const loginRoutes = async(fastify) => {
|
|||||||
wy: wy ? { logged: true, ...wy } : { logged: false },
|
wy: wy ? { logged: true, ...wy } : { logged: false },
|
||||||
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
|
kg: kg ? { logged: true, ...kg, updatedAt: kgCred?.updatedAt } : { logged: false },
|
||||||
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
|
tx: tx ? { logged: true, uin: tx.uin, vipType: txCred?.vipType ?? null, updatedAt: txCred?.updatedAt } : { logged: false },
|
||||||
|
qs: qsPassport.status(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
+28
-1
@@ -141,7 +141,11 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
}
|
}
|
||||||
return result.then(res => {
|
return result.then(res => {
|
||||||
if (!res?.url) throw upstream('获取播放地址失败')
|
if (!res?.url) throw upstream('获取播放地址失败')
|
||||||
return { type: quality, url: res.url }
|
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器)
|
||||||
|
const url = String(res.url).startsWith('/')
|
||||||
|
? `${request.protocol}://${request.headers.host}${res.url}`
|
||||||
|
: res.url
|
||||||
|
return { type: quality, url }
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
throw err.status ? err : upstream(err.message)
|
throw err.status ? err : upstream(err.message)
|
||||||
})
|
})
|
||||||
@@ -161,6 +165,29 @@ export const musicRoutes = async(fastify: any) => {
|
|||||||
},
|
},
|
||||||
}, urlHandler)
|
}, urlHandler)
|
||||||
|
|
||||||
|
// qs 解密流代理:登录态取流返回 /stream/qs/:id 相对地址,本端点完成"签名 track_v2 →
|
||||||
|
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"。raw 豁免 { code, data } 信封包装
|
||||||
|
fastify.get('/stream/qs/:id', {
|
||||||
|
config: { raw: true },
|
||||||
|
schema: {
|
||||||
|
summary: '汽水音乐解密流代理',
|
||||||
|
description: '登录态取流地址(/url/qs 返回的相对地址)。服务端完成 track_v2 签名请求与 cenc-aes-ctr 本地解密,返回明文音频。',
|
||||||
|
tags: ['歌曲资源'],
|
||||||
|
params: params({ id: pId }),
|
||||||
|
},
|
||||||
|
}, async(request, reply) => {
|
||||||
|
const { id } = request.params
|
||||||
|
try {
|
||||||
|
const entry = await fastify.musicSdk.qs.getStreamBuffer(id)
|
||||||
|
reply.type(entry.contentType || 'audio/mp4')
|
||||||
|
reply.header('Accept-Ranges', 'none')
|
||||||
|
reply.header('Content-Length', entry.buffer.length)
|
||||||
|
return reply.send(entry.buffer)
|
||||||
|
} catch (err: any) {
|
||||||
|
throw upstream(err.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const lyricHandler = async(request) => {
|
const lyricHandler = async(request) => {
|
||||||
const { source, id } = request.params
|
const { source, id } = request.params
|
||||||
const { name, interval } = request.query
|
const { name, interval } = request.query
|
||||||
|
|||||||
+2
-2
@@ -41,7 +41,7 @@ const PORT = process.env.PORT || 16688
|
|||||||
const DOCS_PREFIX = '/docs'
|
const DOCS_PREFIX = '/docs'
|
||||||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
||||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)平台。'
|
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)平台。'
|
||||||
|
|
||||||
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@ fastify.register(swagger, {
|
|||||||
{ name: '榜单', description: '排行榜' },
|
{ name: '榜单', description: '排行榜' },
|
||||||
{ name: '歌单', description: '歌单与热搜' },
|
{ name: '歌单', description: '歌单与热搜' },
|
||||||
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
|
{ name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' },
|
||||||
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg token)与状态' },
|
{ name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie、qs 抖音扫码)与状态' },
|
||||||
{ name: '系统', description: '健康检查、平台信息、文档' },
|
{ name: '系统', description: '健康检查、平台信息、文档' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
@echo off
|
@echo off
|
||||||
set SOURCE_ENABLED=true
|
|
||||||
rem start.bat —— 正常启动
|
|
||||||
rem start.bat dev —— 开发模式(node --watch 热重载:改 src/、sdk/ 下代码自动重启)
|
|
||||||
if "%1"=="dev" (
|
if "%1"=="dev" (
|
||||||
pnpm run dev
|
pnpm run dev
|
||||||
) else (
|
) else (
|
||||||
|
|||||||
+50
-2
@@ -128,7 +128,7 @@ if (!running) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 搜索
|
// 搜索
|
||||||
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
for (const source of ['kw', 'kg', 'tx', 'wy', 'qs']) {
|
||||||
await test(`搜索 ${source}`, async() => {
|
await test(`搜索 ${source}`, async() => {
|
||||||
const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`)
|
const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`)
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
@@ -145,10 +145,29 @@ await test('聚合搜索', async() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// URL(用已知歌曲 id,路径参数风格)
|
// URL(用已知歌曲 id,路径参数风格)
|
||||||
await test('URL kw 320k', async() => {
|
// kw 匿名通道 2026-10-01 接入 mobi.s convert_url_with_sign 主通道后:
|
||||||
|
// 免费歌全档位可用,VIP 歌也放行真音频(320k/flac 实测与 N_MINFO 元数据逐字节吻合),
|
||||||
|
// web playUrl 仅作回退(匿名恒 128k)
|
||||||
|
await test('URL kw 128k 免费歌', async() => {
|
||||||
|
const r = await get('/url/kw/151826618?quality=128k')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
return r.data.url.slice(0, 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => {
|
||||||
const r = await get('/url/kw/28423286?quality=320k')
|
const r = await get('/url/kw/28423286?quality=320k')
|
||||||
if (r.code != 0) throw new Error(r.msg)
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
if (!r.data?.url) throw new Error('无 url')
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
// mobi 通道严格校验 bitrate==320 才放行,url 必然是 320k 档
|
||||||
|
return r.data.url.slice(0, 60)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test('URL kw VIP 歌 flac 降级链', async() => {
|
||||||
|
const r = await get('/url/kw/228908?quality=flac24bit')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
// 晴天无 hi-res 档,应逐级降级命中 2000k flac 而非报错
|
||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -166,6 +185,14 @@ await test('URL tx 320k', async() => {
|
|||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// qs 匿名只有 30 秒试听(7079108541549643812 = 陈粒《小半》,2026-10-01 实测可用)
|
||||||
|
await test('URL qs 试听 128k', async() => {
|
||||||
|
const r = await get('/url/qs/7079108541549643812?quality=128k')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
return r.data.url.slice(0, 60)
|
||||||
|
})
|
||||||
|
|
||||||
// 歌词
|
// 歌词
|
||||||
await test('歌词 kw', async() => {
|
await test('歌词 kw', async() => {
|
||||||
const r = await get('/lyric/kw/28423286')
|
const r = await get('/lyric/kw/28423286')
|
||||||
@@ -174,6 +201,13 @@ await test('歌词 kw', async() => {
|
|||||||
return `${r.data.lyric.slice(0, 30)}...`
|
return `${r.data.lyric.slice(0, 30)}...`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test('歌词 qs', async() => {
|
||||||
|
const r = await get('/lyric/qs/7079108541549643812')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.lyric) throw new Error('无歌词')
|
||||||
|
return `${r.data.lyric.slice(0, 30)}...`
|
||||||
|
})
|
||||||
|
|
||||||
// 封面
|
// 封面
|
||||||
await test('封面 kw', async() => {
|
await test('封面 kw', async() => {
|
||||||
const r = await get('/pic/kw/28423286')
|
const r = await get('/pic/kw/28423286')
|
||||||
@@ -182,6 +216,13 @@ await test('封面 kw', async() => {
|
|||||||
return r.data.url.slice(0, 60)
|
return r.data.url.slice(0, 60)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test('封面 qs', async() => {
|
||||||
|
const r = await get('/pic/qs/7079108541549643812')
|
||||||
|
if (r.code != 0) throw new Error(r.msg)
|
||||||
|
if (!r.data?.url) throw new Error('无 url')
|
||||||
|
return r.data.url.slice(0, 60)
|
||||||
|
})
|
||||||
|
|
||||||
// 榜单 & 热搜
|
// 榜单 & 热搜
|
||||||
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
for (const source of ['kw', 'kg', 'tx', 'wy']) {
|
||||||
await test(`榜单 ${source}`, async() => {
|
await test(`榜单 ${source}`, async() => {
|
||||||
@@ -400,6 +441,13 @@ await test('扫码二维码创建 POST /login/wy/qr', async() => {
|
|||||||
return r.body.data.key.slice(0, 18) + '…'
|
return r.body.data.key.slice(0, 18) + '…'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await test('扫码二维码创建 POST /login/qs/qr', async() => {
|
||||||
|
const r = await postJson('/login/qs/qr', {})
|
||||||
|
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
|
||||||
|
if (!r.body.data?.token || !r.body.data?.qrimg?.startsWith('data:image/')) throw new Error('响应不完整')
|
||||||
|
return `scanApp=${r.body.data.scanApp || '?'}`
|
||||||
|
})
|
||||||
|
|
||||||
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
await test('kg 假 token 拒绝 POST /login/kg', async() => {
|
||||||
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 })
|
||||||
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)
|
||||||
|
|||||||
Reference in new issue
Block a user