From 0f9cff2a086f3770a67bb287fef63ca4563c7bd0 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:43:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(qs):=20=E6=B1=BD=E6=B0=B4=E9=9F=B3?= =?UTF-8?q?=E4=B9=90=E5=B9=B3=E5=8F=B0=E6=8E=A5=E5=85=A5=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=8C=BF=E5=90=8D=E6=90=9C=E7=B4=A2/=E8=AF=95=E5=90=AC?= =?UTF-8?q?=E3=80=81=E6=8A=96=E9=9F=B3=E6=89=AB=E7=A0=81=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E3=80=81=E8=A7=A3=E5=AF=86=E6=B5=81=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdk/qs 平台模块(安卓参数指纹匿名搜索)+ sdk/apis/qs 取流: 匿名 30s 试听,登录态走签名 track_v2 + spade key AES-CTR 解密 - /login/qs/qr 抖音扫码登录(授权后 sessionid 解锁完整曲目) - /stream/qs/:id 解密流代理,/url 返回的相对地址按请求 host 补全 - 回归测试覆盖 kw 双通道(VIP 真音频/降级链)与 qs 试听;start.bat 去掉 SOURCE_ENABLED 直启 --- sdk/apis/qs.ts | 209 ++++++++++++++++++ sdk/index.ts | 4 + sdk/qs/crypto/argus.ts | 152 ++++++++++++++ sdk/qs/crypto/audiodecrypt.ts | 385 ++++++++++++++++++++++++++++++++++ sdk/qs/crypto/index.ts | 120 +++++++++++ sdk/qs/crypto/ladon.ts | 18 ++ sdk/qs/crypto/md5.ts | 161 ++++++++++++++ sdk/qs/crypto/protobuf.ts | 54 +++++ sdk/qs/crypto/sign.ts | 56 +++++ sdk/qs/crypto/simon.ts | 47 +++++ sdk/qs/crypto/sm3.ts | 158 ++++++++++++++ sdk/qs/crypto/speck.ts | 79 +++++++ sdk/qs/crypto/ttencrypt.ts | 26 +++ sdk/qs/crypto/unidbgconfig.ts | 27 +++ sdk/qs/device.ts | 154 ++++++++++++++ sdk/qs/index.ts | 30 +++ sdk/qs/lyric.ts | 46 ++++ sdk/qs/musicInfo.ts | 31 +++ sdk/qs/musicSearch.ts | 131 ++++++++++++ sdk/qs/passport.ts | 162 ++++++++++++++ sdk/types.ts | 2 +- src/routes-login.ts | 41 ++++ src/routes.ts | 29 ++- src/server.ts | 4 +- start.bat | 3 - test/smoke-test.ts | 52 ++++- 26 files changed, 2172 insertions(+), 9 deletions(-) create mode 100644 sdk/apis/qs.ts create mode 100644 sdk/qs/crypto/argus.ts create mode 100644 sdk/qs/crypto/audiodecrypt.ts create mode 100644 sdk/qs/crypto/index.ts create mode 100644 sdk/qs/crypto/ladon.ts create mode 100644 sdk/qs/crypto/md5.ts create mode 100644 sdk/qs/crypto/protobuf.ts create mode 100644 sdk/qs/crypto/sign.ts create mode 100644 sdk/qs/crypto/simon.ts create mode 100644 sdk/qs/crypto/sm3.ts create mode 100644 sdk/qs/crypto/speck.ts create mode 100644 sdk/qs/crypto/ttencrypt.ts create mode 100644 sdk/qs/crypto/unidbgconfig.ts create mode 100644 sdk/qs/device.ts create mode 100644 sdk/qs/index.ts create mode 100644 sdk/qs/lyric.ts create mode 100644 sdk/qs/musicInfo.ts create mode 100644 sdk/qs/musicSearch.ts create mode 100644 sdk/qs/passport.ts diff --git a/sdk/apis/qs.ts b/sdk/apis/qs.ts new file mode 100644 index 0000000..4123df5 --- /dev/null +++ b/sdk/apis/qs.ts @@ -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() +const inflight = new Map>() + +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 => { + 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>): 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 => { + 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 => { + 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 } diff --git a/sdk/index.ts b/sdk/index.ts index 45e591f..b576300 100644 --- a/sdk/index.ts +++ b/sdk/index.ts @@ -2,10 +2,12 @@ import kw from './kw/index.ts' import kg from './kg/index.ts' import tx from './tx/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 kgQualitys } from './apis/kg.ts' import { supportQualitys as txQualitys } from './apis/tx.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 { sizeFormate, @@ -25,6 +27,7 @@ const SUPPORTED_SOURCES: Array<{ id: string; name: string; module: PlatformModul { id: 'kg', name: '酷狗音乐', module: kg }, { id: 'tx', name: 'QQ音乐', module: tx }, { id: 'wy', name: '网易音乐', module: wy }, + { id: 'qs', name: '汽水音乐', module: qs }, ] const SOURCE_MODULES = Object.fromEntries(SUPPORTED_SOURCES.map((s) => [s.id, s.module])) @@ -41,6 +44,7 @@ export const supportQuality: Record = { kg: kgQualitys, tx: txQualitys, wy: wyQualitys, + qs: qsQualitys, } const trimStr = (str: unknown): string => (typeof str === 'string' ? str.trim() : str as string) diff --git a/sdk/qs/crypto/argus.ts b/sdk/qs/crypto/argus.ts new file mode 100644 index 0000000..bd2526b --- /dev/null +++ b/sdk/qs/crypto/argus.ts @@ -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 { + return sm3(data).slice(0, 6); +} + +function decodeStub(xssStub: string): Uint8Array { + 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 { + 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 { + 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); +} diff --git a/sdk/qs/crypto/audiodecrypt.ts b/sdk/qs/crypto/audiodecrypt.ts new file mode 100644 index 0000000..0031509 --- /dev/null +++ b/sdk/qs/crypto/audiodecrypt.ts @@ -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, +} \ No newline at end of file diff --git a/sdk/qs/crypto/index.ts b/sdk/qs/crypto/index.ts new file mode 100644 index 0000000..55eb9e2 --- /dev/null +++ b/sdk/qs/crypto/index.ts @@ -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 { + 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 | string; + +function toBytes(input: HashInput): Uint8Array { + 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 => { + const subtle = getSubtle(); + const digest = await subtle.digest("SHA-256", toBytes(input)); + return hex(digest); + }, + sha256bytes: async (input: HashInput): Promise => { + const subtle = getSubtle(); + return subtle.digest("SHA-256", toBytes(input)); + }, + sha512: async (input: HashInput): Promise => { + const subtle = getSubtle(); + const digest = await subtle.digest("SHA-512", toBytes(input)); + return hex(digest); + }, + sha512bytes: async (input: HashInput): Promise => { + const subtle = getSubtle(); + return subtle.digest("SHA-512", toBytes(input)); + }, + md5: async (input: HashInput): Promise => md5( + typeof input === "string" ? input : toBytes(input).buffer, + ), + md5bytes: async (input: HashInput): Promise => unhex(md5( + typeof input === "string" ? input : toBytes(input).buffer, + )), + sm3: async (input: HashInput): Promise => hex(sm3(toBytes(input)).buffer), + sm3bytes: async (input: HashInput): Promise => sm3(toBytes(input)).buffer, +}; diff --git a/sdk/qs/crypto/ladon.ts b/sdk/qs/crypto/ladon.ts new file mode 100644 index 0000000..a94ee1f --- /dev/null +++ b/sdk/qs/crypto/ladon.ts @@ -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 { + 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); +} diff --git a/sdk/qs/crypto/md5.ts b/sdk/qs/crypto/md5.ts new file mode 100644 index 0000000..db71127 --- /dev/null +++ b/sdk/qs/crypto/md5.ts @@ -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(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(); +} diff --git a/sdk/qs/crypto/protobuf.ts b/sdk/qs/crypto/protobuf.ts new file mode 100644 index 0000000..d59176a --- /dev/null +++ b/sdk/qs/crypto/protobuf.ts @@ -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): 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 { + return Uint8Array.from(this.buf); + } +} diff --git a/sdk/qs/crypto/sign.ts b/sdk/qs/crypto/sign.ts new file mode 100644 index 0000000..14e3617 --- /dev/null +++ b/sdk/qs/crypto/sign.ts @@ -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 { + "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 { + 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 | string, + config: UnidbgConfig = defaultUnidbgConfig, +): Promise> { + 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 = 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"; diff --git a/sdk/qs/crypto/simon.ts b/sdk/qs/crypto/simon.ts new file mode 100644 index 0000000..e38fbc4 --- /dev/null +++ b/sdk/qs/crypto/simon.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]; +} diff --git a/sdk/qs/crypto/sm3.ts b/sdk/qs/crypto/sm3.ts new file mode 100644 index 0000000..b713f63 --- /dev/null +++ b/sdk/qs/crypto/sm3.ts @@ -0,0 +1,158 @@ +// Thanks to https://github.com/JuneAndGreen/sm-crypto/blob/master/src/sm2/sm3.js + +type Bytes = ArrayLike; + +/** + * 循环左移 + */ +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 { + 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 { + 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); +} diff --git a/sdk/qs/crypto/speck.ts b/sdk/qs/crypto/speck.ts new file mode 100644 index 0000000..d3efe62 --- /dev/null +++ b/sdk/qs/crypto/speck.ts @@ -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 { + 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> { + 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); +} diff --git a/sdk/qs/crypto/ttencrypt.ts b/sdk/qs/crypto/ttencrypt.ts new file mode 100644 index 0000000..e200986 --- /dev/null +++ b/sdk/qs/crypto/ttencrypt.ts @@ -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 } diff --git a/sdk/qs/crypto/unidbgconfig.ts b/sdk/qs/crypto/unidbgconfig.ts new file mode 100644 index 0000000..103af01 --- /dev/null +++ b/sdk/qs/crypto/unidbgconfig.ts @@ -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, +}; diff --git a/sdk/qs/device.ts b/sdk/qs/device.ts new file mode 100644 index 0000000..0fd9355 --- /dev/null +++ b/sdk/qs/device.ts @@ -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 => { + 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 => { + 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 } diff --git a/sdk/qs/index.ts b/sdk/qs/index.ts new file mode 100644 index 0000000..5dd5660 --- /dev/null +++ b/sdk/qs/index.ts @@ -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 diff --git a/sdk/qs/lyric.ts b/sdk/qs/lyric.ts new file mode 100644 index 0000000..f70533b --- /dev/null +++ b/sdk/qs/lyric.ts @@ -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 +} diff --git a/sdk/qs/musicInfo.ts b/sdk/qs/musicInfo.ts new file mode 100644 index 0000000..107ebec --- /dev/null +++ b/sdk/qs/musicInfo.ts @@ -0,0 +1,31 @@ +// 汽水音乐曲目信息(H5 SEO 通道) +// 接口: https://beta-luna.douyin.com/luna/h5/seo_track?track_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 => { + 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` +} diff --git a/sdk/qs/musicSearch.ts b/sdk/qs/musicSearch.ts new file mode 100644 index 0000000..793ff79 --- /dev/null +++ b/sdk/qs/musicSearch.ts @@ -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', + } + }) + }, +} diff --git a/sdk/qs/passport.ts b/sdk/qs/passport.ts new file mode 100644 index 0000000..d150456 --- /dev/null +++ b/sdk/qs/passport.ts @@ -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[] => { + const raw = headers?.['set-cookie'] + return Array.isArray(raw) ? raw : raw ? [raw] : [] +} + +const cookiePairsFrom = (headers: Record): string[] => + cookieValues(headers).map(c => String(c).split(';')[0].trim()).filter(Boolean) + +const sessionIdFrom = (headers: Record): 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() + +/** 创建扫码登录二维码 */ +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> => { + 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 } diff --git a/sdk/types.ts b/sdk/types.ts index ea049dc..5290b3b 100644 --- a/sdk/types.ts +++ b/sdk/types.ts @@ -2,7 +2,7 @@ // 所有平台模块与根模块的类型锚点。类型只描述实际返回的数据形状,不改变运行时行为。 /** 音乐平台标识 */ -export type Source = 'kw' | 'kg' | 'tx' | 'wy' +export type Source = 'kw' | 'kg' | 'tx' | 'wy' | 'qs' /** 音质标识(wav 仅部分接口声明使用) */ export type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | 'wav' diff --git a/src/routes-login.ts b/src/routes-login.ts index c466d46..4bde48c 100644 --- a/src/routes-login.ts +++ b/src/routes-login.ts @@ -4,6 +4,7 @@ import * as wyAuth from '../sdk/auth/wy.ts' import * as kgAuth from '../sdk/auth/kg.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 { refreshAll } from './refresh.ts' @@ -226,6 +227,45 @@ export const loginRoutes = async(fastify) => { 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', { @@ -252,6 +292,7 @@ export const loginRoutes = async(fastify) => { wy: wy ? { logged: true, ...wy } : { 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 }, + qs: qsPassport.status(), } }) } diff --git a/src/routes.ts b/src/routes.ts index 8edd88a..757f0b6 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -141,7 +141,11 @@ export const musicRoutes = async(fastify: any) => { } return result.then(res => { 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 => { throw err.status ? err : upstream(err.message) }) @@ -161,6 +165,29 @@ export const musicRoutes = async(fastify: any) => { }, }, 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 { source, id } = request.params const { name, interval } = request.query diff --git a/src/server.ts b/src/server.ts index 3ad729e..462d834 100644 --- a/src/server.ts +++ b/src/server.ts @@ -41,7 +41,7 @@ const PORT = process.env.PORT || 16688 const DOCS_PREFIX = '/docs' const API_TITLE = 'MusicServer 聚合音乐 API' 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')) @@ -154,7 +154,7 @@ fastify.register(swagger, { { name: '榜单', description: '排行榜' }, { name: '歌单', description: '歌单与热搜' }, { name: '歌手专辑', description: '歌手信息、歌曲与专辑详情' }, - { name: '登录', description: '账号登录(wy 扫码/手机号、kg token)与状态' }, + { name: '登录', description: '账号登录(wy 扫码/手机号、kg 扫码/token、tx 扫码/cookie、qs 抖音扫码)与状态' }, { name: '系统', description: '健康检查、平台信息、文档' }, ], }, diff --git a/start.bat b/start.bat index b64bff2..3521d75 100644 --- a/start.bat +++ b/start.bat @@ -1,7 +1,4 @@ @echo off -set SOURCE_ENABLED=true -rem start.bat —— 正常启动 -rem start.bat dev —— 开发模式(node --watch 热重载:改 src/、sdk/ 下代码自动重启) if "%1"=="dev" ( pnpm run dev ) else ( diff --git a/test/smoke-test.ts b/test/smoke-test.ts index e158e9b..1b3a11e 100644 --- a/test/smoke-test.ts +++ b/test/smoke-test.ts @@ -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() => { const r = await get(`/search?source=${source}&name=${encodeURIComponent('稻香')}&limit=2`) if (r.code != 0) throw new Error(r.msg) @@ -145,10 +145,29 @@ await test('聚合搜索', async() => { }) // 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') if (r.code != 0) throw new Error(r.msg) 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) }) @@ -166,6 +185,14 @@ await test('URL tx 320k', async() => { 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() => { const r = await get('/lyric/kw/28423286') @@ -174,6 +201,13 @@ await test('歌词 kw', async() => { 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() => { const r = await get('/pic/kw/28423286') @@ -182,6 +216,13 @@ await test('封面 kw', async() => { 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']) { await test(`榜单 ${source}`, async() => { @@ -400,6 +441,13 @@ await test('扫码二维码创建 POST /login/wy/qr', async() => { 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() => { const r = await postJson('/login/kg', { token: 'smoke_fake_token', userid: 0 }) if (r.status !== 401) throw new Error(`期望 401,实际 ${r.status}`)