Add Tag Edit Function & Wasm for Qmc & Kgm

remotes/origin/HEAD
xhacker-zzz 2 years ago
parent 97cd7afc44
commit de14ccb0b3

@ -1,8 +1,8 @@
{ {
"name": "unlock-music", "name": "unlock-music",
"version": "v1.10.0", "version": "v1.10.3",
"ext_build": 0, "ext_build": 0,
"updateInfo": "重写QMC解锁完全支持.mflac*/.mgg*; 支持JOOX解锁", "updateInfo": "完善音乐标签编辑功能,支持编辑更多标签",
"license": "MIT", "license": "MIT",
"description": "Unlock encrypted music file in browser.", "description": "Unlock encrypted music file in browser.",
"repository": { "repository": {
@ -22,7 +22,6 @@
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.16.5", "@babel/preset-typescript": "^7.16.5",
"@jixun/kugou-crypto": "^1.0.3", "@jixun/kugou-crypto": "^1.0.3",
"@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",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

File diff suppressed because one or more lines are too long

@ -0,0 +1,178 @@
<style scoped>
label {
cursor: pointer;
line-height: 1.2;
display: block;
}
.item-desc {
color: #aaa;
font-size: small;
display: block;
line-height: 1.2;
margin-top: 0.2em;
}
.item-desc a {
color: #aaa;
}
form >>> input {
font-family: 'Courier New', Courier, monospace;
}
* >>> .um-edit-dialog {
max-width: 90%;
width: 30em;
}
</style>
<template>
<el-dialog @close="cancel()" title="音乐标签编辑" :visible="show" custom-class="um-edit-dialog" center>
<el-form ref="form" status-icon :model="form" label-width="0">
<section>
<el-image v-show="!editPicture" :src="imgFile.url || picture" style="width: 100px; height: 100px">
<div slot="error" class="image-slot el-image__error">暂无封面</div>
</el-image>
<el-upload v-show="editPicture" :auto-upload="false" :on-change="addFile" :on-remove="rmvFile" :show-file-list="true" :limit="1" list-type="picture" action="" drag>
<i class="el-icon-upload" />
<div class="el-upload__text">将新图片拖到此处<em>点击选择</em><br />以替换自动匹配的图片</div>
<div slot="tip" class="el-upload__tip">
新拖到此处的图片将覆盖原始图片
</div>
</el-upload>
<i
:class="{'el-icon-edit': !editPicture, 'el-icon-check': editPicture}"
@click="changeCover"
></i><br />
标题:
<span v-show="!editTitle">{{title}}</span>
<el-input v-show="editTitle" v-model="title"></el-input>
<i
:class="{'el-icon-edit': !editTitle, 'el-icon-check': editTitle}"
@click="editTitle = !editTitle"
></i><br />
艺术家:
<span v-show="!editArtist">{{artist}}</span>
<el-input v-show="editArtist" v-model="artist"></el-input>
<i
:class="{'el-icon-edit': !editArtist, 'el-icon-check': editArtist}"
@click="editArtist = !editArtist"
></i><br />
专辑:
<span v-show="!editAlbum">{{album}}</span>
<el-input v-show="editAlbum" v-model="album"></el-input>
<i
:class="{'el-icon-edit': !editAlbum, 'el-icon-check': editAlbum}"
@click="editAlbum = !editAlbum"
></i><br />
专辑艺术家:
<span v-show="!editAlbumartist">{{albumartist}}</span>
<el-input v-show="editAlbumartist" v-model="albumartist"></el-input>
<i
:class="{'el-icon-edit': !editAlbumartist, 'el-icon-check': editAlbumartist}"
@click="editAlbumartist = !editAlbumartist"
></i><br />
风格:
<span v-show="!editGenre">{{genre}}</span>
<el-input v-show="editGenre" v-model="genre"></el-input>
<i
:class="{'el-icon-edit': !editGenre, 'el-icon-check': editGenre}"
@click="editGenre = !editGenre"
></i><br />
<p class="item-desc">
为了节省您设备的资源请在确定前充分检查避免反复修改<br />
直接关闭此对话框不会保留所作的更改
</p>
</section>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="emitConfirm()"> </el-button>
</span>
</el-dialog>
</template>
<script>
import Ruby from './Ruby';
export default {
components: {
Ruby,
},
props: {
show: { type: Boolean, required: true },
picture: { type: String | undefined, required: true },
title: { type: String | undefined, required: true },
artist: { type: String | undefined, required: true },
album: { type: String | undefined, required: true },
albumartist: { type: String | undefined, required: true },
genre: { type: String | undefined, required: true },
},
data() {
return {
form: {
},
imgFile: { tmpblob: undefined, blob: undefined, url: undefined },
editPicture: false,
editTitle: false,
editArtist: false,
editAlbum: false,
editAlbumartist: false,
editGenre: false,
};
},
async mounted() {
this.refreshForm();
},
methods: {
addFile(file) {
this.imgFile.tmpblob = file.raw;
},
rmvFile() {
this.imgFile.tmpblob = undefined;
},
changeCover() {
this.editPicture = !this.editPicture;
if (!this.editPicture && this.imgFile.tmpblob) {
this.imgFile.blob = this.imgFile.tmpblob;
if (this.imgFile.url) {
URL.revokeObjectURL(this.imgFile.url);
}
this.imgFile.url = URL.createObjectURL(this.imgFile.blob);
}
},
async refreshForm() {
if (this.imgFile.url) {
URL.revokeObjectURL(this.imgFile.url);
}
this.imgFile = { tmpblob: undefined, blob: undefined, url: undefined };
this.editPicture = false;
this.editTitle = false;
this.editArtist = false;
this.editAlbum = false;
this.editAlbumartist = false;
this.editGenre = false;
},
async cancel() {
this.refreshForm();
this.$emit('cancel');
},
async emitConfirm() {
if (this.editPicture) {
this.changeCover();
}
if (this.imgFile.url) {
URL.revokeObjectURL(this.imgFile.url);
}
this.$emit('ok', {
picture: this.imgFile.blob,
title: this.title,
artist: this.artist,
album: this.album,
albumartist: this.albumartist,
genre: this.genre,
});
},
},
};
</script>

@ -27,6 +27,7 @@
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)"> <el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
</el-button> </el-button>
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button> <el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button>
<el-button circle icon="el-icon-edit" @click="handleEdit(scope.row)"></el-button>
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)"> <el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
</el-button> </el-button>
</template> </template>
@ -55,6 +56,9 @@ export default {
handleDownload(row) { handleDownload(row) {
this.$emit('download', row); this.$emit('download', row);
}, },
handleEdit(row) {
this.$emit('edit', row);
},
}, },
}; };
</script> </script>

@ -8,6 +8,7 @@ import {
} from '@/decrypt/utils'; } from '@/decrypt/utils';
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import { DecryptResult } from '@/decrypt/entity'; import { DecryptResult } from '@/decrypt/entity';
import { DecryptKgmWasm } from '@/decrypt/kgm_wasm';
import { decryptKgmByteAtOffsetV2, decryptVprByteAtOffset } from '@jixun/kugou-crypto/dist/utils/decryptionHelper'; import { decryptKgmByteAtOffsetV2, decryptVprByteAtOffset } from '@jixun/kugou-crypto/dist/utils/decryptionHelper';
//prettier-ignore //prettier-ignore
@ -22,31 +23,48 @@ const KgmHeader = [
] ]
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file)); const oriData = await GetArrayBuffer(file);
if (raw_ext === 'vpr') { if (raw_ext === 'vpr') {
if (!BytesHasPrefix(oriData, VprHeader)) throw Error('Not a valid vpr file!'); if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!');
} else { } else {
if (!BytesHasPrefix(oriData, KgmHeader)) throw Error('Not a valid kgm(a) file!'); if (!BytesHasPrefix(new Uint8Array(oriData), KgmHeader)) throw Error('Not a valid kgm(a) file!');
} }
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer); let musicDecoded: Uint8Array | undefined;
let headerLen = bHeaderLen.getUint32(0, true); if (globalThis.WebAssembly) {
console.log('kgm: using wasm decoder');
let audioData = oriData.slice(headerLen); const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext);
let dataLen = audioData.length; // 若 v2 检测失败,降级到 v1 再尝试一次
if (kgmDecrypted.success) {
musicDecoded = kgmDecrypted.data;
console.log('kgm wasm decoder suceeded');
} else {
console.warn('KgmWasm failed with error %s', kgmDecrypted.error || '(no error)');
}
}
if (!musicDecoded) {
musicDecoded = new Uint8Array(oriData);
let bHeaderLen = new DataView(musicDecoded.slice(0x10, 0x14).buffer);
let headerLen = bHeaderLen.getUint32(0, true);
let key1 = Array.from(musicDecoded.slice(0x1c, 0x2c));
key1.push(0);
let key1 = Array.from(oriData.slice(0x1c, 0x2c)); musicDecoded = musicDecoded.slice(headerLen);
key1.push(0); let dataLen = musicDecoded.length;
const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2; const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2;
for (let i = 0; i < dataLen; i++) { for (let i = 0; i < dataLen; i++) {
audioData[i] = decryptByte(audioData[i], key1, i); musicDecoded[i] = decryptByte(musicDecoded[i], key1, i);
}
} }
const ext = SniffAudioExt(audioData); const ext = SniffAudioExt(musicDecoded);
const mime = AudioMimeType[ext]; const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime }); let musicBlob = new Blob([musicDecoded], { type: mime });
const musicMeta = await metaParseBlob(musicBlob); const musicMeta = await metaParseBlob(musicBlob);
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artists == undefined ? musicMeta.common.artist : musicMeta.common.artists.toString());
return { return {
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),

@ -0,0 +1,67 @@
import KgmCryptoModule from '@/KgmWasm/KgmWasmBundle';
import { MergeUint8Array } from '@/utils/MergeUint8Array';
// 每次处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
export interface KGMDecryptionResult {
success: boolean;
data: Uint8Array;
error: string;
}
/**
* KGM
*
* Uint8Array
* @param {ArrayBuffer} kgmBlob Blob
*/
export async function DecryptKgmWasm(kgmBlob: ArrayBuffer, ext: string): Promise<KGMDecryptionResult> {
const result: KGMDecryptionResult = { success: false, data: new Uint8Array(), error: '' };
// 初始化模组
let KgmCrypto: any;
try {
KgmCrypto = await KgmCryptoModule();
} catch (err: any) {
result.error = err?.message || 'wasm 加载失败';
return result;
}
if (!KgmCrypto) {
result.error = 'wasm 加载失败';
return result;
}
// 申请内存块,并文件末端数据到 WASM 的内存堆
let kgmBuf = new Uint8Array(kgmBlob);
const pQmcBuf = KgmCrypto._malloc(DECRYPTION_BUF_SIZE);
KgmCrypto.writeArrayToMemory(kgmBuf.slice(0, DECRYPTION_BUF_SIZE), pQmcBuf);
// 进行解密初始化
const headerSize = KgmCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext);
console.log(headerSize);
kgmBuf = kgmBuf.slice(headerSize);
const decryptedParts = [];
let offset = 0;
let bytesToDecrypt = kgmBuf.length;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(kgmBuf.slice(offset, offset + blockSize));
KgmCrypto.writeArrayToMemory(blockData, pQmcBuf);
KgmCrypto.decBlob(pQmcBuf, blockSize, offset);
decryptedParts.push(KgmCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + blockSize));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
KgmCrypto._free(pQmcBuf);
result.data = MergeUint8Array(decryptedParts);
result.success = true;
return result;
}

@ -38,7 +38,7 @@ export async function Decrypt(file: File, raw_filename: string, _: string): Prom
let musicBlob = new Blob([audioData], { type: mime }); let musicBlob = new Blob([audioData], { type: mime });
const musicMeta = await metaParseBlob(musicBlob); const musicMeta = await metaParseBlob(musicBlob);
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artists == undefined ? musicMeta.common.artist : musicMeta.common.artists.toString());
return { return {
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),

@ -13,7 +13,7 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
const ext = SniffAudioExt(buffer, raw_ext); const ext = SniffAudioExt(buffer, raw_ext);
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] }); if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
const tag = await metaParseBlob(file); const tag = await metaParseBlob(file);
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist); const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artists == undefined ? tag.common.artist : tag.common.artists.toString());
return { return {
title, title,

@ -3,7 +3,7 @@ import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
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 { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
import { extractQQMusicMeta } from '@/utils/qm_meta'; import { extractQQMusicMeta } from '@/utils/qm_meta';
interface Handler { interface Handler {
@ -24,9 +24,9 @@ export const HandlerMap: { [key: string]: Handler } = {
qmcflac: { ext: 'flac', version: 2 }, qmcflac: { ext: 'flac', version: 2 },
qmcogg: { ext: 'ogg', version: 2 }, qmcogg: { ext: 'ogg', version: 2 },
qmc0: { ext: 'mp3', version: 1 }, qmc0: { ext: 'mp3', version: 2 },
qmc2: { ext: 'ogg', version: 1 }, qmc2: { ext: 'ogg', version: 2 },
qmc3: { ext: 'mp3', version: 1 }, qmc3: { ext: 'mp3', version: 2 },
bkcmp3: { ext: 'mp3', version: 1 }, bkcmp3: { ext: 'mp3', version: 1 },
bkcflac: { ext: 'flac', version: 1 }, bkcflac: { ext: 'flac', version: 1 },
tkm: { ext: 'm4a', version: 1 }, tkm: { ext: 'm4a', version: 1 },
@ -49,13 +49,14 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
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, raw_ext);
// 若 v2 检测失败,降级到 v1 再尝试一次 // 若 v2 检测失败,降级到 v1 再尝试一次
if (v2Decrypted.success) { if (v2Decrypted.success) {
musicDecoded = v2Decrypted.data; musicDecoded = v2Decrypted.data;
musicID = v2Decrypted.songId; musicID = v2Decrypted.songId;
console.log('qmc wasm decoder suceeded');
} else { } else {
console.warn('qmc2-wasm failed with error %s', v2Decrypted.error || '(no error)'); console.warn('QmcWasm failed with error %s', v2Decrypted.error || '(no error)');
} }
} }
@ -151,7 +152,7 @@ export class QmcDecoder {
} 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);
if (keySize < 0x300) { if (keySize < 0x400) {
this.audioSize = this.size - keySize - 4; this.audioSize = this.size - keySize - 4;
const rawKey = this.file.subarray(this.audioSize, this.size - 4); const rawKey = this.file.subarray(this.audioSize, this.size - 4);
this.setCipher(rawKey); this.setCipher(rawKey);

@ -5,12 +5,14 @@ const ZERO_LEN = 7;
export function QmcDeriveKey(raw: Uint8Array): Uint8Array { export function QmcDeriveKey(raw: Uint8Array): Uint8Array {
const textDec = new TextDecoder(); const textDec = new TextDecoder();
const rawDec = Buffer.from(textDec.decode(raw), 'base64'); let rawDec = Buffer.from(textDec.decode(raw), 'base64');
let n = rawDec.length; let n = rawDec.length;
if (n < 16) { if (n < 16) {
throw Error('key length is too short'); throw Error('key length is too short');
} }
rawDec = decryptV2Key(rawDec);
const simpleKey = simpleMakeKey(106, 8); const simpleKey = simpleMakeKey(106, 8);
let teaKey = new Uint8Array(16); let teaKey = new Uint8Array(16);
for (let i = 0; i < 8; i++) { for (let i = 0; i < 8; i++) {
@ -32,6 +34,30 @@ export function simpleMakeKey(salt: number, length: number): number[] {
return keyBuf; return keyBuf;
} }
const mixKey1: Uint8Array = new Uint8Array([ 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28 ])
const mixKey2: Uint8Array = new Uint8Array([ 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54 ])
const v2KeyPrefix: Uint8Array = new Uint8Array([ 0x51, 0x51, 0x4D, 0x75, 0x73, 0x69, 0x63, 0x20, 0x45, 0x6E, 0x63, 0x56, 0x32, 0x2C, 0x4B, 0x65, 0x79, 0x3A ])
function decryptV2Key(key: Buffer): Buffer
{
const textEnc = new TextDecoder();
if (key.length < 18 || textEnc.decode(key.slice(0, 18)) !== 'QQMusic EncV2,Key:') {
return key;
}
let out = decryptTencentTea(key.slice(18), mixKey1);
out = decryptTencentTea(out, mixKey2);
const textDec = new TextDecoder();
const keyDec = Buffer.from(textDec.decode(out), 'base64');
let n = keyDec.length;
if (n < 16) {
throw Error('EncV2 key decode failed');
}
return keyDec;
}
function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array { function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array {
if (inBuf.length % 8 != 0) { if (inBuf.length % 8 != 0) {
throw Error('inBuf size not a multiple of the block size'); throw Error('inBuf size not a multiple of the block size');

@ -1,14 +1,10 @@
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle'; import QmcCryptoModule from '@/QmcWasm/QmcWasmBundle';
import { MergeUint8Array } from '@/utils/MergeUint8Array'; import { MergeUint8Array } from '@/utils/MergeUint8Array';
import { QMCCrypto } from '@jixun/qmc2-crypto/QMCCrypto';
// 检测文件末端使用的缓冲区大小
const DETECTION_SIZE = 40;
// 每次处理 2M 的数据 // 每次处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024; const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
export interface QMC2DecryptionResult { export interface QMCDecryptionResult {
success: boolean; success: boolean;
data: Uint8Array; data: Uint8Array;
songId: string | number; songId: string | number;
@ -16,96 +12,62 @@ export interface QMC2DecryptionResult {
} }
/** /**
* QMC2 * QMC
* *
* Uint8Array * Uint8Array
* @param {ArrayBuffer} mggBlob Blob * @param {ArrayBuffer} qmcBlob Blob
*/ */
export async function DecryptQMCWasm(mggBlob: ArrayBuffer): Promise<QMC2DecryptionResult> { export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise<QMCDecryptionResult> {
const result: QMC2DecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' }; const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
// 初始化模组 // 初始化模组
let QMCCrypto: QMCCrypto; let QmcCrypto: any;
try { try {
QMCCrypto = await QMCCryptoModule(); QmcCrypto = await QmcCryptoModule();
} catch (err: any) { } catch (err: any) {
result.error = err?.message || 'wasm 加载失败'; result.error = err?.message || 'wasm 加载失败';
return result; return result;
} }
if (!QmcCrypto) {
// 申请内存块,并文件末端数据到 WASM 的内存堆 result.error = 'wasm 加载失败';
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE)); return result;
const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length);
QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf);
// 检测结果内存块
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
// 进行检测
const detectOK = QMCCrypto.detectKeyEndPosition(pDetectionResult, pDetectionBuf, detectionBuf.length);
// 提取结构体内容:
// (pos: i32; len: i32; error: char[??])
const position = QMCCrypto.getValue(pDetectionResult, '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);
} }
// 释放内存 // 申请内存块,并文件末端数据到 WASM 的内存堆
QMCCrypto._free(pDetectionBuf); const qmcBuf = new Uint8Array(qmcBlob);
QMCCrypto._free(pDetectionResult); const pQmcBuf = QmcCrypto._malloc(DECRYPTION_BUF_SIZE);
QmcCrypto.writeArrayToMemory(qmcBuf.slice(-DECRYPTION_BUF_SIZE), pQmcBuf);
if (!detectOK) {
// 进行解密初始化
ext = '.' + ext;
const tailSize = QmcCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext);
if (tailSize == -1) {
result.error = QmcCrypto.getError();
return result; return result;
} else {
result.songId = QmcCrypto.getSongId();
result.songId = result.songId == "0" ? 0 : result.songId;
} }
// 计算解密后文件的大小。
// 之前得到的 position 为相对当前检测数据起点的偏移。
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
// 提取嵌入到文件的 EKey
const ekey = new Uint8Array(mggBlob.slice(decryptedSize, decryptedSize + len));
// 解码 UTF-8 数据到 string
const decoder = new TextDecoder();
const ekey_b64 = decoder.decode(ekey);
// 初始化加密与缓冲区
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64);
const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE);
const decryptedParts = []; const decryptedParts = [];
let offset = 0; let offset = 0;
let bytesToDecrypt = decryptedSize; let bytesToDecrypt = qmcBuf.length - tailSize;
while (bytesToDecrypt > 0) { while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段 // 解密一些片段
const blockData = new Uint8Array(mggBlob.slice(offset, offset + blockSize)); const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize));
QMCCrypto.writeArrayToMemory(blockData, buf); QmcCrypto.writeArrayToMemory(blockData, pQmcBuf);
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize); decryptedParts.push(QmcCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCrypto.decBlob(pQmcBuf, blockSize, offset)));
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
offset += blockSize; offset += blockSize;
bytesToDecrypt -= blockSize; bytesToDecrypt -= blockSize;
} }
QMCCrypto._free(buf); QmcCrypto._free(pQmcBuf);
hCrypto.delete();
result.data = MergeUint8Array(decryptedParts); result.data = MergeUint8Array(decryptedParts);
result.success = true;
return result; return result;
} }

@ -8,34 +8,53 @@ import {
} from '@/decrypt/utils'; } from '@/decrypt/utils';
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc'; import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc';
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
import { DecryptResult } from '@/decrypt/entity'; import { DecryptResult } from '@/decrypt/entity';
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import { parseBlob as metaParseBlob } from 'music-metadata-browser';
export async function Decrypt(file: Blob, raw_filename: string, _: string): Promise<DecryptResult> { export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file)); const buffer = await GetArrayBuffer(file);
let length = buffer.length;
for (let i = 0; i < length; i++) { let musicDecoded: Uint8Array | undefined;
buffer[i] ^= 0xf4; if (globalThis.WebAssembly) {
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4; console.log('qmc: using wasm decoder');
else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2; const qmcDecrypted = await DecryptQmcWasm(buffer, raw_ext);
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3; // 若 qmc 检测失败,降级到 v1 再尝试一次
if (qmcDecrypted.success) {
musicDecoded = qmcDecrypted.data;
console.log('qmc wasm decoder suceeded');
} else {
console.warn('QmcWasm failed with error %s', qmcDecrypted.error || '(no error)');
}
}
if (!musicDecoded) {
musicDecoded = new Uint8Array(buffer);
let length = musicDecoded.length;
for (let i = 0; i < length; i++) {
musicDecoded[i] ^= 0xf4;
if (musicDecoded[i] <= 0x3f) musicDecoded[i] = musicDecoded[i] * 4;
else if (musicDecoded[i] <= 0x7f) musicDecoded[i] = (musicDecoded[i] - 0x40) * 4 + 1;
else if (musicDecoded[i] <= 0xbf) musicDecoded[i] = (musicDecoded[i] - 0x80) * 4 + 2;
else musicDecoded[i] = (musicDecoded[i] - 0xc0) * 4 + 3;
}
} }
let ext = SniffAudioExt(buffer, ''); let ext = SniffAudioExt(musicDecoded, '');
const newName = SplitFilename(raw_filename); const newName = SplitFilename(raw_filename);
let audioBlob: Blob; let audioBlob: Blob;
if (ext !== '' || newName.ext === 'mp3') { if (ext !== '' || newName.ext === 'mp3') {
audioBlob = new Blob([buffer], { type: AudioMimeType[ext] }); audioBlob = new Blob([musicDecoded], { type: AudioMimeType[ext] });
} else if (newName.ext in HandlerMap) { } else if (newName.ext in HandlerMap) {
audioBlob = new Blob([buffer], { type: 'application/octet-stream' }); audioBlob = new Blob([musicDecoded], { type: 'application/octet-stream' });
return QmcDecrypt(audioBlob, newName.name, newName.ext); return QmcDecrypt(audioBlob, newName.name, newName.ext);
} else { } else {
throw '不支持的QQ音乐缓存格式'; throw '不支持的QQ音乐缓存格式';
} }
const tag = await metaParseBlob(audioBlob); const tag = await metaParseBlob(audioBlob);
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist); const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artists == undefined ? tag.common.artist : tag.common.artists.toString());
return { return {
title, title,

@ -17,7 +17,7 @@ export async function Decrypt(
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] }); if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
} }
const tag = await metaParseBlob(file); const tag = await metaParseBlob(file);
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist); const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artists == undefined ? tag.common.artist : tag.common.artists.toString());
return { return {
title, title,

@ -2,6 +2,8 @@ import { IAudioMetadata } from 'music-metadata-browser';
import ID3Writer from 'browser-id3-writer'; import ID3Writer from 'browser-id3-writer';
import MetaFlac from 'metaflac-js'; import MetaFlac from 'metaflac-js';
export const split_regex = /[ ]?[,;/_、][ ]?/;
export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43]; export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43];
export const MP3_HEADER = [0x49, 0x44, 0x33]; export const MP3_HEADER = [0x49, 0x44, 0x33];
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53]; export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53];
@ -91,7 +93,7 @@ export function GetMetaFromFile(
const items = filename.split(separator); const items = filename.split(separator);
if (items.length > 1) { if (items.length > 1) {
if (!meta.artist) meta.artist = items[0].trim(); if (!meta.artist || meta.artist.split(split_regex).length < items[0].trim().split(split_regex).length) meta.artist = items[0].trim();
if (!meta.title) meta.title = items[1].trim(); if (!meta.title) meta.title = items[1].trim();
} else if (items.length === 1) { } else if (items.length === 1) {
if (!meta.title) meta.title = items[0].trim(); if (!meta.title) meta.title = items[0].trim();
@ -119,6 +121,8 @@ export interface IMusicMeta {
title: string; title: string;
artists?: string[]; artists?: string[];
album?: string; album?: string;
albumartist?: string;
genre?: string[];
picture?: ArrayBuffer; picture?: ArrayBuffer;
picture_desc?: string; picture_desc?: string;
} }
@ -169,6 +173,83 @@ export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: I
return writer.save(); return writer.save();
} }
export function RewriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new ID3Writer(audioData);
// reserve original data
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [];
frames.forEach((frame) => {
if (frame.id !== 'TPE1'
&& frame.id !== 'TIT2'
&& frame.id !== 'TALB'
&& frame.id !== 'TPE2'
&& frame.id !== 'TCON'
) {
try {
writer.setFrame(frame.id, frame.value);
} catch (e) {
throw new Error('write unknown mp3 frame failed');
}
}
});
const old = original.common;
writer
.setFrame('TPE1', info?.artists || old.artists || [])
.setFrame('TIT2', info?.title || old.title)
.setFrame('TALB', info?.album || old.album || '')
.setFrame('TPE2', info?.albumartist || old.albumartist || '')
.setFrame('TCON', info?.genre || old.genre || []);
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || '',
});
}
return writer.addTag();
}
export function RewriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new MetaFlac(audioData);
const old = original.common;
if (info.title) {
if (old.title) {
writer.removeTag('TITLE');
}
writer.setTag('TITLE=' + info.title);
}
if (info.album) {
if (old.album) {
writer.removeTag('ALBUM');
}
writer.setTag('ALBUM=' + info.album);
}
if (info.albumartist) {
if (old.albumartist) {
writer.removeTag('ALBUMARTIST');
}
writer.setTag('ALBUMARTIST=' + info.albumartist);
}
if (info.artists) {
if (old.artists) {
writer.removeTag('ARTIST');
}
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
}
if (info.genre) {
if (old.genre) {
writer.removeTag('GENRE');
}
info.genre.forEach((singlegenre) => writer.setTag('GENRE=' + singlegenre));
}
if (info.picture) {
writer.importPictureFromBuffer(Buffer.from(info.picture));
}
return writer.save();
}
export function SplitFilename(n: string): { name: string; ext: string } { export function SplitFilename(n: string): { name: string; ext: string } {
const pos = n.lastIndexOf('.'); const pos = n.lastIndexOf('.');
return { return {

@ -49,7 +49,7 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
const { title, artist } = GetMetaFromFile( const { title, artist } = GetMetaFromFile(
raw_filename, raw_filename,
musicMeta.common.title, musicMeta.common.title,
musicMeta.common.artist, musicMeta.common.artists == undefined ? musicMeta.common.artist : musicMeta.common.artists.toString(),
raw_filename.indexOf('_') === -1 ? '-' : '_', raw_filename.indexOf('_') === -1 ? '-' : '_',
); );

@ -8,6 +8,7 @@ import {
WriteMetaToFlac, WriteMetaToFlac,
WriteMetaToMp3, WriteMetaToMp3,
AudioMimeType, AudioMimeType,
split_regex,
} from '@/decrypt/utils'; } from '@/decrypt/utils';
import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api'; import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api';
@ -38,13 +39,20 @@ export async function extractQQMusicMeta(
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue; if (!musicMeta.native.hasOwnProperty(metaIdx)) continue;
if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) { if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) {
console.warn('try using gbk encoding to decode meta'); console.warn('try using gbk encoding to decode meta');
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk'); musicMeta.common.artist = '';
if (musicMeta.common.artists == undefined) {
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk');
}
else {
musicMeta.common.artists.forEach((artist) => artist = iconv.decode(new Buffer(artist ?? ''), 'gbk'));
musicMeta.common.artist = musicMeta.common.artists.toString();
}
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk'); musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk');
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk'); musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk');
} }
} }
if (id) { if (id && id !== '0') {
try { try {
return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob); return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob);
} catch (e) { } catch (e) {
@ -67,7 +75,7 @@ export async function extractQQMusicMeta(
imgUrl: imageURL, imgUrl: imageURL,
blob: await writeMetaToAudioFile({ blob: await writeMetaToAudioFile({
title: info.title, title: info.title,
artists: info.artist.split(' _ '), artists: info.artist.split(split_regex),
ext, ext,
imageURL, imageURL,
musicMeta, musicMeta,
@ -88,7 +96,7 @@ async function fetchMetadataFromSongId(
return { return {
title: info.track_info.title, title: info.track_info.title,
artist: artists.join(''), artist: artists.join(','),
album: info.track_info.album.name, album: info.track_info.album.name,
imgUrl: imageURL, imgUrl: imageURL,

@ -10,6 +10,15 @@
</el-radio> </el-radio>
</el-row> </el-row>
<el-row> <el-row>
<edit-dialog
:show="showEditDialog"
:picture="editing_data.picture"
:title="editing_data.title"
:artist="editing_data.artist"
:album="editing_data.album"
:albumartist="editing_data.albumartist"
:genre="editing_data.genre"
@cancel="showEditDialog = false" @ok="handleEdit"></edit-dialog>
<config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog> <config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog>
<el-tooltip class="item" effect="dark" placement="top"> <el-tooltip class="item" effect="dark" placement="top">
<div slot="content"> <div slot="content">
@ -35,7 +44,7 @@
<audio :autoplay="playing_auto" :src="playing_url" controls /> <audio :autoplay="playing_auto" :src="playing_url" controls />
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying" /> <PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @edit="editFile" @play="changePlaying" />
</div> </div>
</template> </template>
@ -43,8 +52,11 @@
import FileSelector from '@/component/FileSelector'; import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable'; import PreviewTable from '@/component/PreviewTable';
import ConfigDialog from '@/component/ConfigDialog'; import ConfigDialog from '@/component/ConfigDialog';
import EditDialog from '@/component/EditDialog';
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils'; import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
import { GetImageFromURL, RewriteMetaToMp3, RewriteMetaToFlac, AudioMimeType, split_regex } from '@/decrypt/utils';
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
export default { export default {
name: 'Home', name: 'Home',
@ -52,10 +64,13 @@ export default {
FileSelector, FileSelector,
PreviewTable, PreviewTable,
ConfigDialog, ConfigDialog,
EditDialog,
}, },
data() { data() {
return { return {
showConfigDialog: false, showConfigDialog: false,
showEditDialog: false,
editing_data: { picture: '', title: '', artist: '', album: '', albumartist: '', genre: '', },
tableData: [], tableData: [],
playing_url: '', playing_url: '',
playing_auto: false, playing_auto: false,
@ -128,7 +143,56 @@ export default {
} }
}, 300); }, 300);
}, },
async handleEdit(data) {
this.showEditDialog = false;
URL.revokeObjectURL(this.editing_data.file);
if (data.picture) {
URL.revokeObjectURL(this.editing_data.picture);
this.editing_data.picture = URL.createObjectURL(data.picture);
}
this.editing_data.title = data.title;
this.editing_data.artist = data.artist;
this.editing_data.album = data.album;
try {
const musicMeta = await metaParseBlob(new Blob([this.editing_data.blob], { type: mime }));
const imageInfo = await GetImageFromURL(this.editing_data.picture);
if (!imageInfo) {
console.warn('获取图像失败', this.editing_data.picture);
}
const newMeta = { picture: imageInfo?.buffer,
title: data.title,
artists: data.artist.split(split_regex),
album: data.album,
albumartist: data.albumartist,
genre: data.genre.split(split_regex)
};
const buffer = Buffer.from(await this.editing_data.blob.arrayBuffer());
const mime = AudioMimeType[this.editing_data.ext] || AudioMimeType.mp3;
if (this.editing_data.ext === 'mp3') {
this.editing_data.blob = new Blob([RewriteMetaToMp3(buffer, newMeta, musicMeta)], { type: mime });
} else if (this.editing_data.ext === 'flac') {
this.editing_data.blob = new Blob([RewriteMetaToFlac(buffer, newMeta, musicMeta)], { type: mime });
} else {
console.info('writing metadata for ' + info.ext + ' is not being supported for now');
}
} catch (e) {
console.warn('Error while appending cover image to file ' + e);
}
this.editing_data.file = URL.createObjectURL(this.editing_data.blob);/**/
this.$notify.success({
title: '修改成功',
message: '成功修改 ' + this.editing_data.title,
duration: 3000,
});
},
async editFile(data) {
this.editing_data = data;
const musicMeta = await metaParseBlob(this.editing_data.blob);
this.editing_data.albumartist = musicMeta.common.albumartist || '';
this.editing_data.genre = musicMeta.common.genre?.toString() || '';
this.showEditDialog = true;
},
async saveFile(data) { async saveFile(data) {
if (this.dir) { if (this.dir) {
await DirectlyWriteFile(data, this.filename_policy, this.dir); await DirectlyWriteFile(data, this.filename_policy, this.dir);

Loading…
Cancel
Save