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",
"version": "v1.10.0",
"version": "v1.10.3",
"ext_build": 0,
"updateInfo": "重写QMC解锁完全支持.mflac*/.mgg*; 支持JOOX解锁",
"updateInfo": "完善音乐标签编辑功能,支持编辑更多标签",
"license": "MIT",
"description": "Unlock encrypted music file in browser.",
"repository": {
@ -22,7 +22,6 @@
"dependencies": {
"@babel/preset-typescript": "^7.16.5",
"@jixun/kugou-crypto": "^1.0.3",
"@jixun/qmc2-crypto": "^0.0.6-R1",
"@unlock-music/joox-crypto": "^0.0.1-R5",
"base64-js": "^1.5.1",
"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>
<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>
</template>
@ -55,6 +56,9 @@ export default {
handleDownload(row) {
this.$emit('download', row);
},
handleEdit(row) {
this.$emit('edit', row);
},
},
};
</script>

@ -8,6 +8,7 @@ import {
} from '@/decrypt/utils';
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import { DecryptResult } from '@/decrypt/entity';
import { DecryptKgmWasm } from '@/decrypt/kgm_wasm';
import { decryptKgmByteAtOffsetV2, decryptVprByteAtOffset } from '@jixun/kugou-crypto/dist/utils/decryptionHelper';
//prettier-ignore
@ -22,31 +23,48 @@ const KgmHeader = [
]
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 (!BytesHasPrefix(oriData, VprHeader)) throw Error('Not a valid vpr file!');
if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!');
} 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 musicDecoded: Uint8Array | undefined;
if (globalThis.WebAssembly) {
console.log('kgm: using wasm decoder');
const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext);
// 若 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)');
}
}
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer);
let headerLen = bHeaderLen.getUint32(0, true);
let audioData = oriData.slice(headerLen);
let dataLen = audioData.length;
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(oriData.slice(0x1c, 0x2c));
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++) {
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];
let musicBlob = new Blob([audioData], { type: mime });
let musicBlob = new Blob([musicDecoded], { type: mime });
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 {
album: musicMeta.common.album,
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 });
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 {
album: musicMeta.common.album,
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);
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
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 {
title,

@ -3,7 +3,7 @@ import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
import { DecryptResult } from '@/decrypt/entity';
import { QmcDeriveKey } from '@/decrypt/qmc_key';
import { DecryptQMCWasm } from '@/decrypt/qmc_wasm';
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
import { extractQQMusicMeta } from '@/utils/qm_meta';
interface Handler {
@ -24,9 +24,9 @@ export const HandlerMap: { [key: string]: Handler } = {
qmcflac: { ext: 'flac', version: 2 },
qmcogg: { ext: 'ogg', version: 2 },
qmc0: { ext: 'mp3', version: 1 },
qmc2: { ext: 'ogg', version: 1 },
qmc3: { ext: 'mp3', version: 1 },
qmc0: { ext: 'mp3', version: 2 },
qmc2: { ext: 'ogg', version: 2 },
qmc3: { ext: 'mp3', version: 2 },
bkcmp3: { ext: 'mp3', version: 1 },
bkcflac: { ext: 'flac', 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) {
console.log('qmc: using wasm decoder');
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
const v2Decrypted = await DecryptQmcWasm(fileBuffer, raw_ext);
// 若 v2 检测失败,降级到 v1 再尝试一次
if (v2Decrypted.success) {
musicDecoded = v2Decrypted.data;
musicID = v2Decrypted.songId;
console.log('qmc wasm decoder suceeded');
} 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 {
const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset);
const keySize = sizeView.getUint32(0, true);
if (keySize < 0x300) {
if (keySize < 0x400) {
this.audioSize = this.size - keySize - 4;
const rawKey = this.file.subarray(this.audioSize, this.size - 4);
this.setCipher(rawKey);

@ -5,12 +5,14 @@ const ZERO_LEN = 7;
export function QmcDeriveKey(raw: Uint8Array): Uint8Array {
const textDec = new TextDecoder();
const rawDec = Buffer.from(textDec.decode(raw), 'base64');
let rawDec = Buffer.from(textDec.decode(raw), 'base64');
let n = rawDec.length;
if (n < 16) {
throw Error('key length is too short');
}
rawDec = decryptV2Key(rawDec);
const simpleKey = simpleMakeKey(106, 8);
let teaKey = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
@ -32,6 +34,30 @@ export function simpleMakeKey(salt: number, length: number): number[] {
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 {
if (inBuf.length % 8 != 0) {
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 { QMCCrypto } from '@jixun/qmc2-crypto/QMCCrypto';
// 检测文件末端使用的缓冲区大小
const DETECTION_SIZE = 40;
// 每次处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
export interface QMC2DecryptionResult {
export interface QMCDecryptionResult {
success: boolean;
data: Uint8Array;
songId: string | number;
@ -16,96 +12,62 @@ export interface QMC2DecryptionResult {
}
/**
* QMC2
* QMC
*
* Uint8Array
* @param {ArrayBuffer} mggBlob Blob
* @param {ArrayBuffer} qmcBlob Blob
*/
export async function DecryptQMCWasm(mggBlob: ArrayBuffer): Promise<QMC2DecryptionResult> {
const result: QMC2DecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise<QMCDecryptionResult> {
const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
// 初始化模组
let QMCCrypto: QMCCrypto;
let QmcCrypto: any;
try {
QMCCrypto = await QMCCryptoModule();
QmcCrypto = await QmcCryptoModule();
} catch (err: any) {
result.error = err?.message || 'wasm 加载失败';
return result;
}
// 申请内存块,并文件末端数据到 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);
if (!QmcCrypto) {
result.error = 'wasm 加载失败';
return result;
}
// 释放内存
QMCCrypto._free(pDetectionBuf);
QMCCrypto._free(pDetectionResult);
if (!detectOK) {
// 申请内存块,并文件末端数据到 WASM 的内存堆
const qmcBuf = new Uint8Array(qmcBlob);
const pQmcBuf = QmcCrypto._malloc(DECRYPTION_BUF_SIZE);
QmcCrypto.writeArrayToMemory(qmcBuf.slice(-DECRYPTION_BUF_SIZE), pQmcBuf);
// 进行解密初始化
ext = '.' + ext;
const tailSize = QmcCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext);
if (tailSize == -1) {
result.error = QmcCrypto.getError();
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 = [];
let offset = 0;
let bytesToDecrypt = decryptedSize;
let bytesToDecrypt = qmcBuf.length - tailSize;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(mggBlob.slice(offset, offset + blockSize));
QMCCrypto.writeArrayToMemory(blockData, buf);
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize));
QmcCrypto.writeArrayToMemory(blockData, pQmcBuf);
decryptedParts.push(QmcCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCrypto.decBlob(pQmcBuf, blockSize, offset)));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
QMCCrypto._free(buf);
hCrypto.delete();
QmcCrypto._free(pQmcBuf);
result.data = MergeUint8Array(decryptedParts);
result.success = true;
return result;
}

@ -8,34 +8,53 @@ import {
} from '@/decrypt/utils';
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc';
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
import { DecryptResult } from '@/decrypt/entity';
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
export async function Decrypt(file: Blob, raw_filename: string, _: string): Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let length = buffer.length;
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const buffer = await GetArrayBuffer(file);
let musicDecoded: Uint8Array | undefined;
if (globalThis.WebAssembly) {
console.log('qmc: using wasm decoder');
const qmcDecrypted = await DecryptQmcWasm(buffer, raw_ext);
// 若 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++) {
buffer[i] ^= 0xf4;
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
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);
let audioBlob: Blob;
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) {
audioBlob = new Blob([buffer], { type: 'application/octet-stream' });
audioBlob = new Blob([musicDecoded], { type: 'application/octet-stream' });
return QmcDecrypt(audioBlob, newName.name, newName.ext);
} else {
throw '不支持的QQ音乐缓存格式';
}
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 {
title,

@ -17,7 +17,7 @@ export async function Decrypt(
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
}
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 {
title,

@ -2,6 +2,8 @@ import { IAudioMetadata } from 'music-metadata-browser';
import ID3Writer from 'browser-id3-writer';
import MetaFlac from 'metaflac-js';
export const split_regex = /[ ]?[,;/_、][ ]?/;
export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43];
export const MP3_HEADER = [0x49, 0x44, 0x33];
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53];
@ -91,7 +93,7 @@ export function GetMetaFromFile(
const items = filename.split(separator);
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();
} else if (items.length === 1) {
if (!meta.title) meta.title = items[0].trim();
@ -119,6 +121,8 @@ export interface IMusicMeta {
title: string;
artists?: string[];
album?: string;
albumartist?: string;
genre?: string[];
picture?: ArrayBuffer;
picture_desc?: string;
}
@ -169,6 +173,83 @@ export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: I
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 } {
const pos = n.lastIndexOf('.');
return {

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

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

@ -10,6 +10,15 @@
</el-radio>
</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>
<el-tooltip class="item" effect="dark" placement="top">
<div slot="content">
@ -35,7 +44,7 @@
<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>
</template>
@ -43,8 +52,11 @@
import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable';
import ConfigDialog from '@/component/ConfigDialog';
import EditDialog from '@/component/EditDialog';
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 {
name: 'Home',
@ -52,10 +64,13 @@ export default {
FileSelector,
PreviewTable,
ConfigDialog,
EditDialog,
},
data() {
return {
showConfigDialog: false,
showEditDialog: false,
editing_data: { picture: '', title: '', artist: '', album: '', albumartist: '', genre: '', },
tableData: [],
playing_url: '',
playing_auto: false,
@ -128,7 +143,56 @@ export default {
}
}, 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) {
if (this.dir) {
await DirectlyWriteFile(data, this.filename_policy, this.dir);

Loading…
Cancel
Save