Add Tag Edit Function & Wasm for Qmc & Kgm
This commit is contained in:
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",
|
||||||
|
34
src/KgmWasm/KgmLegacy.js
Normal file
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
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
BIN
src/KgmWasm/KgmWasm.wasm
Normal file
Binary file not shown.
21
src/KgmWasm/KgmWasmBundle.js
Normal file
21
src/KgmWasm/KgmWasmBundle.js
Normal file
File diff suppressed because one or more lines are too long
34
src/QmcWasm/QmcLegacy.js
Normal file
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
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
BIN
src/QmcWasm/QmcWasm.wasm
Normal file
Binary file not shown.
21
src/QmcWasm/QmcWasmBundle.js
Normal file
21
src/QmcWasm/QmcWasmBundle.js
Normal file
File diff suppressed because one or more lines are too long
178
src/component/EditDialog.vue
Normal file
178
src/component/EditDialog.vue
Normal 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>
|
@ -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;
|
||||||
|
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)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!musicDecoded) {
|
||||||
|
musicDecoded = new Uint8Array(oriData);
|
||||||
|
let bHeaderLen = new DataView(musicDecoded.slice(0x10, 0x14).buffer);
|
||||||
let headerLen = bHeaderLen.getUint32(0, true);
|
let headerLen = bHeaderLen.getUint32(0, true);
|
||||||
|
|
||||||
let audioData = oriData.slice(headerLen);
|
let key1 = Array.from(musicDecoded.slice(0x1c, 0x2c));
|
||||||
let dataLen = audioData.length;
|
|
||||||
|
|
||||||
let key1 = Array.from(oriData.slice(0x1c, 0x2c));
|
|
||||||
key1.push(0);
|
key1.push(0);
|
||||||
|
|
||||||
|
musicDecoded = musicDecoded.slice(headerLen);
|
||||||
|
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),
|
||||||
|
67
src/decrypt/kgm_wasm.ts
Normal file
67
src/decrypt/kgm_wasm.ts
Normal 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;
|
||||||
|
}
|
@ -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));
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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 = '';
|
||||||
|
if (musicMeta.common.artists == undefined) {
|
||||||
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk');
|
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…
Reference in New Issue
Block a user