forked from um/web
Merge pull request #221 from unlock-music/feature/qmc-meta
feature(QMCv2): 通过 SongID 在线查询歌曲信息
This commit is contained in:
commit
74d0a4137e
14
package-lock.json
generated
14
package-lock.json
generated
@ -11,7 +11,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/preset-typescript": "^7.16.5",
|
"@babel/preset-typescript": "^7.16.5",
|
||||||
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
"@jixun/qmc2-crypto": "^0.0.6-R1",
|
||||||
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"browser-id3-writer": "^4.4.0",
|
"browser-id3-writer": "^4.4.0",
|
||||||
@ -2986,9 +2986,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@jixun/qmc2-crypto": {
|
"node_modules/@jixun/qmc2-crypto": {
|
||||||
"version": "0.0.5-R4",
|
"version": "0.0.6-R1",
|
||||||
"resolved": "https://registry.npmjs.org/@jixun/qmc2-crypto/-/qmc2-crypto-0.0.5-R4.tgz",
|
"resolved": "https://registry.npmjs.org/@jixun/qmc2-crypto/-/qmc2-crypto-0.0.6-R1.tgz",
|
||||||
"integrity": "sha512-4xGClhxMd1BL7UjE+fZr+a4GYkfEjwU216WZ89ouANwR8Q27PhQrra+msEvM4J/mBBCjv/x/eIcS67XBasHKUQ=="
|
"integrity": "sha512-G7oa28/tGozJIIkF2DS7RWewoDsKrmGM5JgthzCfB6P1psfCjpjwH21RhnY9RzNlfdGZBqyWkAKwXMiUx/xhNA=="
|
||||||
},
|
},
|
||||||
"node_modules/@mrmlnc/readdir-enhanced": {
|
"node_modules/@mrmlnc/readdir-enhanced": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
@ -23168,9 +23168,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"@jixun/qmc2-crypto": {
|
"@jixun/qmc2-crypto": {
|
||||||
"version": "0.0.5-R4",
|
"version": "0.0.6-R1",
|
||||||
"resolved": "https://registry.npmjs.org/@jixun/qmc2-crypto/-/qmc2-crypto-0.0.5-R4.tgz",
|
"resolved": "https://registry.npmjs.org/@jixun/qmc2-crypto/-/qmc2-crypto-0.0.6-R1.tgz",
|
||||||
"integrity": "sha512-4xGClhxMd1BL7UjE+fZr+a4GYkfEjwU216WZ89ouANwR8Q27PhQrra+msEvM4J/mBBCjv/x/eIcS67XBasHKUQ=="
|
"integrity": "sha512-G7oa28/tGozJIIkF2DS7RWewoDsKrmGM5JgthzCfB6P1psfCjpjwH21RhnY9RzNlfdGZBqyWkAKwXMiUx/xhNA=="
|
||||||
},
|
},
|
||||||
"@mrmlnc/readdir-enhanced": {
|
"@mrmlnc/readdir-enhanced": {
|
||||||
"version": "2.2.1",
|
"version": "2.2.1",
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/preset-typescript": "^7.16.5",
|
"@babel/preset-typescript": "^7.16.5",
|
||||||
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
"@jixun/qmc2-crypto": "^0.0.6-R1",
|
||||||
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"browser-id3-writer": "^4.4.0",
|
"browser-id3-writer": "^4.4.0",
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
|
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
|
||||||
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
|
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
|
||||||
import { DecryptQMCWasm } from './qmc_wasm';
|
|
||||||
|
|
||||||
import { DecryptResult } from '@/decrypt/entity';
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import { QmcDeriveKey } from '@/decrypt/qmc_key';
|
import { QmcDeriveKey } from '@/decrypt/qmc_key';
|
||||||
|
import { DecryptQMCWasm } from '@/decrypt/qmc_wasm';
|
||||||
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
||||||
|
|
||||||
interface Handler {
|
interface Handler {
|
||||||
@ -42,20 +42,27 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
|
|
||||||
const fileBuffer = await GetArrayBuffer(file);
|
const fileBuffer = await GetArrayBuffer(file);
|
||||||
let musicDecoded: Uint8Array | undefined;
|
let musicDecoded: Uint8Array | undefined;
|
||||||
|
let musicID: number | string | undefined;
|
||||||
|
|
||||||
if (version === 2 && globalThis.WebAssembly) {
|
if (version === 2 && globalThis.WebAssembly) {
|
||||||
console.log('qmc: using wasm decoder');
|
console.log('qmc: using wasm decoder');
|
||||||
|
|
||||||
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
|
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
|
||||||
// 如果 v2 检测失败,降级到 v1 再尝试一次
|
// 若 v2 检测失败,降级到 v1 再尝试一次
|
||||||
if (v2Decrypted) {
|
if (v2Decrypted.success) {
|
||||||
musicDecoded = v2Decrypted;
|
musicDecoded = v2Decrypted.data;
|
||||||
|
musicID = v2Decrypted.songId;
|
||||||
|
} else {
|
||||||
|
console.warn('qmc2-wasm failed with error %s', v2Decrypted.error || '(no error)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!musicDecoded) {
|
if (!musicDecoded) {
|
||||||
// may throw error
|
// may throw error
|
||||||
console.log('qmc: using js decoder');
|
console.log('qmc: using js decoder');
|
||||||
const d = new QmcDecoder(new Uint8Array(fileBuffer));
|
const d = new QmcDecoder(new Uint8Array(fileBuffer));
|
||||||
musicDecoded = d.decrypt();
|
musicDecoded = d.decrypt();
|
||||||
|
musicID = d.songID;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
||||||
@ -65,6 +72,7 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
new Blob([musicDecoded], { type: mime }),
|
new Blob([musicDecoded], { type: mime }),
|
||||||
raw_filename,
|
raw_filename,
|
||||||
ext,
|
ext,
|
||||||
|
musicID,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -81,19 +89,25 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
|
|
||||||
export class QmcDecoder {
|
export class QmcDecoder {
|
||||||
private static readonly BYTE_COMMA = ','.charCodeAt(0);
|
private static readonly BYTE_COMMA = ','.charCodeAt(0);
|
||||||
file: Uint8Array;
|
private readonly file: Uint8Array;
|
||||||
size: number;
|
private readonly size: number;
|
||||||
decoded: boolean = false;
|
private decoded: boolean = false;
|
||||||
audioSize?: number;
|
private audioSize?: number;
|
||||||
cipher?: QmcStreamCipher;
|
private cipher?: QmcStreamCipher;
|
||||||
|
|
||||||
constructor(file: Uint8Array) {
|
public constructor(file: Uint8Array) {
|
||||||
this.file = file;
|
this.file = file;
|
||||||
this.size = file.length;
|
this.size = file.length;
|
||||||
this.searchKey();
|
this.searchKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(): Uint8Array {
|
private _songID?: number;
|
||||||
|
|
||||||
|
public get songID() {
|
||||||
|
return this._songID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public decrypt(): Uint8Array {
|
||||||
if (!this.cipher) {
|
if (!this.cipher) {
|
||||||
throw new Error('no cipher found');
|
throw new Error('no cipher found');
|
||||||
}
|
}
|
||||||
@ -118,9 +132,20 @@ export class QmcDecoder {
|
|||||||
const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset);
|
const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset);
|
||||||
const keySize = sizeView.getUint32(0, false);
|
const keySize = sizeView.getUint32(0, false);
|
||||||
this.audioSize = this.size - keySize - 8;
|
this.audioSize = this.size - keySize - 8;
|
||||||
|
|
||||||
const rawKey = this.file.subarray(this.audioSize, this.size - 8);
|
const rawKey = this.file.subarray(this.audioSize, this.size - 8);
|
||||||
const keyEnd = rawKey.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
|
const keyEnd = rawKey.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
|
||||||
|
if (keyEnd < 0) {
|
||||||
|
throw new Error('invalid key: search raw key failed');
|
||||||
|
}
|
||||||
this.setCipher(rawKey.subarray(0, keyEnd));
|
this.setCipher(rawKey.subarray(0, keyEnd));
|
||||||
|
|
||||||
|
const idBuf = rawKey.subarray(keyEnd + 1);
|
||||||
|
const idEnd = idBuf.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
|
||||||
|
if (keyEnd < 0) {
|
||||||
|
throw new Error('invalid key: search song id failed');
|
||||||
|
}
|
||||||
|
this._songID = parseInt(textEnc.decode(idBuf.subarray(0, idEnd)), 10);
|
||||||
} else {
|
} else {
|
||||||
const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset);
|
const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset);
|
||||||
const keySize = sizeView.getUint32(0, true);
|
const keySize = sizeView.getUint32(0, true);
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
|
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
|
||||||
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||||
|
import { QMCCrypto } from '@jixun/qmc2-crypto/QMCCrypto';
|
||||||
|
|
||||||
// 检测文件末端使用的缓冲区大小
|
// 检测文件末端使用的缓冲区大小
|
||||||
const DETECTION_SIZE = 40;
|
const DETECTION_SIZE = 40;
|
||||||
@ -7,16 +8,31 @@ const DETECTION_SIZE = 40;
|
|||||||
// 每次处理 2M 的数据
|
// 每次处理 2M 的数据
|
||||||
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
|
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
export interface QMC2DecryptionResult {
|
||||||
|
success: boolean;
|
||||||
|
data: Uint8Array;
|
||||||
|
songId: string | number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解密一个 QMC2 加密的文件。
|
* 解密一个 QMC2 加密的文件。
|
||||||
*
|
*
|
||||||
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
|
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
|
||||||
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
|
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
|
||||||
* @return {Promise<Uint8Array|false>}
|
|
||||||
*/
|
*/
|
||||||
export async function DecryptQMCWasm(mggBlob: ArrayBuffer) {
|
export async function DecryptQMCWasm(mggBlob: ArrayBuffer): Promise<QMC2DecryptionResult> {
|
||||||
|
const result: QMC2DecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
|
||||||
|
|
||||||
// 初始化模组
|
// 初始化模组
|
||||||
const QMCCrypto = await QMCCryptoModule();
|
let QMCCrypto: QMCCrypto;
|
||||||
|
|
||||||
|
try {
|
||||||
|
QMCCrypto = await QMCCryptoModule();
|
||||||
|
} catch (err: any) {
|
||||||
|
result.error = err?.message || 'wasm 加载失败';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// 申请内存块,并文件末端数据到 WASM 的内存堆
|
// 申请内存块,并文件末端数据到 WASM 的内存堆
|
||||||
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
|
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
|
||||||
@ -34,12 +50,26 @@ export async function DecryptQMCWasm(mggBlob: ArrayBuffer) {
|
|||||||
const position = QMCCrypto.getValue(pDetectionResult, 'i32');
|
const position = QMCCrypto.getValue(pDetectionResult, 'i32');
|
||||||
const len = QMCCrypto.getValue(pDetectionResult + 4, 'i32');
|
const len = QMCCrypto.getValue(pDetectionResult + 4, 'i32');
|
||||||
|
|
||||||
|
result.success = detectOK;
|
||||||
|
result.error = QMCCrypto.UTF8ToString(
|
||||||
|
pDetectionResult + QMCCrypto.offsetof_error_msg(),
|
||||||
|
QMCCrypto.sizeof_error_msg(),
|
||||||
|
);
|
||||||
|
const songId = QMCCrypto.UTF8ToString(pDetectionResult + QMCCrypto.offsetof_song_id(), QMCCrypto.sizeof_song_id());
|
||||||
|
if (!songId) {
|
||||||
|
console.debug('qmc2-wasm: songId not found');
|
||||||
|
} else if (/^\d+$/.test(songId)) {
|
||||||
|
result.songId = songId;
|
||||||
|
} else {
|
||||||
|
console.warn('qmc2-wasm: Invalid songId: %s', songId);
|
||||||
|
}
|
||||||
|
|
||||||
// 释放内存
|
// 释放内存
|
||||||
QMCCrypto._free(pDetectionBuf);
|
QMCCrypto._free(pDetectionBuf);
|
||||||
QMCCrypto._free(pDetectionResult);
|
QMCCrypto._free(pDetectionResult);
|
||||||
|
|
||||||
if (!detectOK) {
|
if (!detectOK) {
|
||||||
return false;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算解密后文件的大小。
|
// 计算解密后文件的大小。
|
||||||
@ -75,5 +105,7 @@ export async function DecryptQMCWasm(mggBlob: ArrayBuffer) {
|
|||||||
QMCCrypto._free(buf);
|
QMCCrypto._free(buf);
|
||||||
hCrypto.delete();
|
hCrypto.delete();
|
||||||
|
|
||||||
return MergeUint8Array(decryptedParts);
|
result.data = MergeUint8Array(decryptedParts);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user