1
0
forked from um/web

Add Tag Edit Function & Wasm for Qmc & Kgm

This commit is contained in:
xhacker-zzz 2022-11-20 22:30:56 +08:00
parent 97cd7afc44
commit de14ccb0b3
24 changed files with 699 additions and 120 deletions

View File

@ -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",

34
src/KgmWasm/KgmLegacy.js Normal file

File diff suppressed because one or more lines are too long

21
src/KgmWasm/KgmWasm.js Normal file

File diff suppressed because one or more lines are too long

BIN
src/KgmWasm/KgmWasm.wasm Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

34
src/QmcWasm/QmcLegacy.js Normal file

File diff suppressed because one or more lines are too long

21
src/QmcWasm/QmcWasm.js Normal file

File diff suppressed because one or more lines are too long

BIN
src/QmcWasm/QmcWasm.wasm Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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>

View File

@ -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) {
let key1 = Array.from(oriData.slice(0x1c, 0x2c)); musicDecoded = kgmDecrypted.data;
key1.push(0); console.log('kgm wasm decoder suceeded');
} else {
const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2; console.warn('KgmWasm failed with error %s', kgmDecrypted.error || '(no error)');
for (let i = 0; i < dataLen; i++) { }
audioData[i] = decryptByte(audioData[i], key1, i);
} }
const ext = SniffAudioExt(audioData); 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);
musicDecoded = musicDecoded.slice(headerLen);
let dataLen = musicDecoded.length;
const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2;
for (let i = 0; i < dataLen; i++) {
musicDecoded[i] = decryptByte(musicDecoded[i], key1, i);
}
}
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),

67
src/decrypt/kgm_wasm.ts Normal file
View File

@ -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;
}

View File

@ -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),

View File

@ -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,

View File

@ -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);

View File

@ -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');

View File

@ -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));
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);
}
// 释放内存
QMCCrypto._free(pDetectionBuf);
QMCCrypto._free(pDetectionResult);
if (!detectOK) {
return result; return result;
} }
// 计算解密后文件的大小。 // 申请内存块,并文件末端数据到 WASM 的内存堆
// 之前得到的 position 为相对当前检测数据起点的偏移。 const qmcBuf = new Uint8Array(qmcBlob);
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position; const pQmcBuf = QmcCrypto._malloc(DECRYPTION_BUF_SIZE);
QmcCrypto.writeArrayToMemory(qmcBuf.slice(-DECRYPTION_BUF_SIZE), pQmcBuf);
// 提取嵌入到文件的 EKey // 进行解密初始化
const ekey = new Uint8Array(mggBlob.slice(decryptedSize, decryptedSize + len)); ext = '.' + ext;
const tailSize = QmcCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext);
// 解码 UTF-8 数据到 string if (tailSize == -1) {
const decoder = new TextDecoder(); result.error = QmcCrypto.getError();
const ekey_b64 = decoder.decode(ekey); return result;
} else {
// 初始化加密与缓冲区 result.songId = QmcCrypto.getSongId();
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64); result.songId = result.songId == "0" ? 0 : result.songId;
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;
} }

View File

@ -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)');
}
} }
let ext = SniffAudioExt(buffer, '');
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(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,

View File

@ -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,

View File

@ -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 {

View File

@ -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 ? '-' : '_',
); );

View File

@ -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,

View File

@ -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);