all: format with prettier
(cherry picked from commit cad5b4d7deba4fbe4a40a17306ce49d3b2f13139)
This commit is contained in:
parent
5dc89502cb
commit
3441b7a3b1
44
src/App.vue
44
src/App.vue
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container id="app">
|
<el-container id="app">
|
||||||
<el-main>
|
<el-main>
|
||||||
<Home/>
|
<Home />
|
||||||
</el-main>
|
</el-main>
|
||||||
<el-footer id="app-footer">
|
<el-footer id="app-footer">
|
||||||
<el-row>
|
<el-row>
|
||||||
@ -10,12 +10,12 @@
|
|||||||
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
|
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row>
|
<el-row>
|
||||||
目前支持网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
|
目前支持 网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
|
||||||
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。
|
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。
|
||||||
</el-row>
|
</el-row>
|
||||||
<el-row>
|
<el-row>
|
||||||
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上-->
|
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上-->
|
||||||
<span>Copyright © 2019 - {{ (new Date()).getFullYear() }} MengYX</span>
|
<span>Copyright © 2019 - {{ new Date().getFullYear() }} MengYX</span>
|
||||||
音乐解锁使用
|
音乐解锁使用
|
||||||
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
|
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
|
||||||
开放源代码
|
开放源代码
|
||||||
@ -25,46 +25,48 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import FileSelector from '@/component/FileSelector';
|
||||||
import FileSelector from "@/component/FileSelector"
|
import PreviewTable from '@/component/PreviewTable';
|
||||||
import PreviewTable from "@/component/PreviewTable"
|
import config from '@/../package.json';
|
||||||
import config from "@/../package.json"
|
import Home from '@/view/Home';
|
||||||
import Home from "@/view/Home";
|
import { checkUpdate } from '@/utils/api';
|
||||||
import {checkUpdate} from "@/utils/api";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'app',
|
name: 'app',
|
||||||
components: {
|
components: {
|
||||||
FileSelector,
|
FileSelector,
|
||||||
PreviewTable,
|
PreviewTable,
|
||||||
Home
|
Home,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
version: config.version,
|
version: config.version,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.$nextTick(() => this.finishLoad());
|
this.$nextTick(() => this.finishLoad());
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async finishLoad() {
|
async finishLoad() {
|
||||||
const mask = document.getElementById("loader-mask");
|
const mask = document.getElementById('loader-mask');
|
||||||
if (!!mask) mask.remove();
|
if (!!mask) mask.remove();
|
||||||
let updateInfo;
|
let updateInfo;
|
||||||
try {
|
try {
|
||||||
updateInfo = await checkUpdate(this.version)
|
updateInfo = await checkUpdate(this.version);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("check version info failed", e)
|
console.warn('check version info failed', e);
|
||||||
}
|
}
|
||||||
if ((updateInfo && process.env.NODE_ENV === 'production') && (updateInfo.HttpsFound ||
|
if (
|
||||||
(updateInfo.Found && window.location.protocol !== "https:"))) {
|
updateInfo &&
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
(updateInfo.HttpsFound || (updateInfo.Found && window.location.protocol !== 'https:'))
|
||||||
|
) {
|
||||||
this.$notify.warning({
|
this.$notify.warning({
|
||||||
title: '发现更新',
|
title: '发现更新',
|
||||||
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
|
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
|
||||||
dangerouslyUseHTMLString: true,
|
dangerouslyUseHTMLString: true,
|
||||||
duration: 15000,
|
duration: 15000,
|
||||||
position: 'top-left'
|
position: 'top-left',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.$notify.info({
|
this.$notify.info({
|
||||||
@ -72,14 +74,14 @@ export default {
|
|||||||
message: `我们使用PWA技术,无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
|
message: `我们使用PWA技术,无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
|
||||||
dangerouslyUseHTMLString: true,
|
dangerouslyUseHTMLString: true,
|
||||||
duration: 10000,
|
duration: 10000,
|
||||||
position: 'top-left'
|
position: 'top-left',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import "scss/unlock-music";
|
@import 'scss/unlock-music';
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,38 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-upload
|
<el-upload :auto-upload="false" :on-change="addFile" :show-file-list="false" action="" drag multiple>
|
||||||
:auto-upload="false"
|
<i class="el-icon-upload" />
|
||||||
:on-change="addFile"
|
|
||||||
:show-file-list="false"
|
|
||||||
action=""
|
|
||||||
drag
|
|
||||||
multiple>
|
|
||||||
<i class="el-icon-upload"/>
|
|
||||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||||||
<div slot="tip" class="el-upload__tip">
|
<div slot="tip" class="el-upload__tip">
|
||||||
<div>
|
<div>
|
||||||
仅在浏览器内对文件进行解锁,无需消耗流量
|
仅在浏览器内对文件进行解锁,无需消耗流量
|
||||||
<el-tooltip effect="dark" placement="top-start">
|
<el-tooltip effect="dark" placement="top-start">
|
||||||
<div slot="content">
|
<div slot="content">算法在源代码中已经提供,所有运算都发生在本地</div>
|
||||||
算法在源代码中已经提供,所有运算都发生在本地
|
<i class="el-icon-info" style="font-size: 12px" />
|
||||||
</div>
|
|
||||||
<i class="el-icon-info" style="font-size: 12px"/>
|
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
工作模式: {{ parallel ? "多线程 Worker" : "单线程 Queue" }}
|
工作模式: {{ parallel ? '多线程 Worker' : '单线程 Queue' }}
|
||||||
<el-tooltip effect="dark" placement="top-start">
|
<el-tooltip effect="dark" placement="top-start">
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
将此工具部署在HTTPS环境下,可以启用Web Worker特性,<br/>
|
将此工具部署在HTTPS环境下,可以启用Web Worker特性,<br />
|
||||||
从而更快的利用并行处理完成解锁
|
从而更快的利用并行处理完成解锁
|
||||||
</div>
|
</div>
|
||||||
<i class="el-icon-info" style="font-size: 12px"/>
|
<i class="el-icon-info" style="font-size: 12px" />
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<transition name="el-fade-in"><!--todo: add delay to animation-->
|
<transition name="el-fade-in"
|
||||||
|
><!--todo: add delay to animation-->
|
||||||
<el-progress
|
<el-progress
|
||||||
v-show="progress_show" :format="progress_string" :percentage="progress_value"
|
v-show="progress_show"
|
||||||
:stroke-width="16" :text-inside="true"
|
:format="progress_string"
|
||||||
|
:percentage="progress_value"
|
||||||
|
:stroke-width="16"
|
||||||
|
:text-inside="true"
|
||||||
style="margin: 16px 6px 0 6px"
|
style="margin: 16px 6px 0 6px"
|
||||||
></el-progress>
|
></el-progress>
|
||||||
</transition>
|
</transition>
|
||||||
@ -40,60 +36,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {spawn, Worker, Pool} from "threads"
|
import { spawn, Worker, Pool } from 'threads';
|
||||||
import {CommonDecrypt} from "@/decrypt/common.ts";
|
import { CommonDecrypt } from '@/decrypt/common.ts';
|
||||||
import {DecryptQueue} from "@/utils/utils";
|
import { DecryptQueue } from '@/utils/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "FileSelector",
|
name: 'FileSelector',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
task_all: 0,
|
task_all: 0,
|
||||||
task_finished: 0,
|
task_finished: 0,
|
||||||
queue: new DecryptQueue(), // for http or file protocol
|
queue: new DecryptQueue(), // for http or file protocol
|
||||||
parallel: false
|
parallel: false,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
progress_value() {
|
progress_value() {
|
||||||
return this.task_all ? this.task_finished / this.task_all * 100 : 0
|
return this.task_all ? (this.task_finished / this.task_all) * 100 : 0;
|
||||||
},
|
},
|
||||||
progress_show() {
|
progress_show() {
|
||||||
return this.task_all !== this.task_finished
|
return this.task_all !== this.task_finished;
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (window.Worker && window.location.protocol !== "file:" && process.env.NODE_ENV === 'production') {
|
if (window.Worker && window.location.protocol !== 'file:' && process.env.NODE_ENV === 'production') {
|
||||||
console.log("Using Worker Pool")
|
console.log('Using Worker Pool');
|
||||||
this.queue = Pool(
|
this.queue = Pool(() => spawn(new Worker('@/utils/worker.ts')), navigator.hardwareConcurrency || 1);
|
||||||
() => spawn(new Worker('@/utils/worker.ts')),
|
this.parallel = true;
|
||||||
navigator.hardwareConcurrency || 1
|
|
||||||
)
|
|
||||||
this.parallel = true
|
|
||||||
} else {
|
} else {
|
||||||
console.log("Using Queue in Main Thread")
|
console.log('Using Queue in Main Thread');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
progress_string() {
|
progress_string() {
|
||||||
return `${this.task_finished} / ${this.task_all}`
|
return `${this.task_finished} / ${this.task_all}`;
|
||||||
},
|
},
|
||||||
async addFile(file) {
|
async addFile(file) {
|
||||||
this.task_all++
|
this.task_all++;
|
||||||
this.queue.queue(async (dec = CommonDecrypt) => {
|
this.queue.queue(async (dec = CommonDecrypt) => {
|
||||||
console.log("start handling", file.name)
|
console.log('start handling', file.name);
|
||||||
try {
|
try {
|
||||||
this.$emit("success", await dec(file));
|
this.$emit('success', await dec(file));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
this.$emit("error", e, file.name)
|
this.$emit('error', e, file.name);
|
||||||
} finally {
|
} finally {
|
||||||
this.task_finished++
|
this.task_finished++;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,12 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-table :data="tableData" style="width: 100%">
|
<el-table :data="tableData" style="width: 100%">
|
||||||
|
|
||||||
<el-table-column label="封面">
|
<el-table-column label="封面">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
|
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
|
||||||
<div slot="error" class="image-slot el-image__error">
|
<div slot="error" class="image-slot el-image__error">暂无封面</div>
|
||||||
暂无封面
|
|
||||||
</div>
|
|
||||||
</el-image>
|
</el-image>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -27,14 +24,10 @@
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作">
|
<el-table-column label="操作">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button circle
|
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
|
||||||
icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
|
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button circle
|
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button>
|
||||||
icon="el-icon-download" @click="handleDownload(scope.row)">
|
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
|
||||||
</el-button>
|
|
||||||
<el-button circle
|
|
||||||
icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
|
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@ -42,30 +35,28 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {RemoveBlobMusic} from '@/utils/utils'
|
import { RemoveBlobMusic } from '@/utils/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "PreviewTable",
|
name: 'PreviewTable',
|
||||||
props: {
|
props: {
|
||||||
tableData: {type: Array, required: true},
|
tableData: { type: Array, required: true },
|
||||||
policy: {type: Number, required: true}
|
policy: { type: Number, required: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
handlePlay(index, row) {
|
handlePlay(index, row) {
|
||||||
this.$emit("play", row.file);
|
this.$emit('play', row.file);
|
||||||
},
|
},
|
||||||
handleDelete(index, row) {
|
handleDelete(index, row) {
|
||||||
RemoveBlobMusic(row);
|
RemoveBlobMusic(row);
|
||||||
this.tableData.splice(index, 1);
|
this.tableData.splice(index, 1);
|
||||||
},
|
},
|
||||||
handleDownload(row) {
|
handleDownload(row) {
|
||||||
this.$emit("download", row)
|
this.$emit('download', row);
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
@ -1,76 +1,75 @@
|
|||||||
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
|
import { Decrypt as NcmDecrypt } from '@/decrypt/ncm';
|
||||||
import {Decrypt as NcmCacheDecrypt} from "@/decrypt/ncmcache";
|
import { Decrypt as NcmCacheDecrypt } from '@/decrypt/ncmcache';
|
||||||
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
|
import { Decrypt as XmDecrypt } from '@/decrypt/xm';
|
||||||
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
|
import { Decrypt as QmcDecrypt } from '@/decrypt/qmc';
|
||||||
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
|
import { Decrypt as QmcCacheDecrypt } from '@/decrypt/qmccache';
|
||||||
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
|
import { Decrypt as KgmDecrypt } from '@/decrypt/kgm';
|
||||||
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
|
import { Decrypt as KwmDecrypt } from '@/decrypt/kwm';
|
||||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||||
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
|
import { Decrypt as TmDecrypt } from '@/decrypt/tm';
|
||||||
import {DecryptResult, FileInfo} from "@/decrypt/entity";
|
import { DecryptResult, FileInfo } from '@/decrypt/entity';
|
||||||
import {SplitFilename} from "@/decrypt/utils";
|
import { SplitFilename } from '@/decrypt/utils';
|
||||||
|
|
||||||
|
|
||||||
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
||||||
const raw = SplitFilename(file.name)
|
const raw = SplitFilename(file.name);
|
||||||
let rt_data: DecryptResult;
|
let rt_data: DecryptResult;
|
||||||
switch (raw.ext) {
|
switch (raw.ext) {
|
||||||
case "ncm":// Netease Mp3/Flac
|
case 'ncm': // Netease Mp3/Flac
|
||||||
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break;
|
break;
|
||||||
case "uc":// Netease Cache
|
case 'uc': // Netease Cache
|
||||||
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break;
|
break;
|
||||||
case "kwm":// Kuwo Mp3/Flac
|
case 'kwm': // Kuwo Mp3/Flac
|
||||||
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break
|
break;
|
||||||
case "xm": // Xiami Wav/M4a/Mp3/Flac
|
case 'xm': // Xiami Wav/M4a/Mp3/Flac
|
||||||
case "wav":// Xiami/Raw Wav
|
case 'wav': // Xiami/Raw Wav
|
||||||
case "mp3":// Xiami/Raw Mp3
|
case 'mp3': // Xiami/Raw Mp3
|
||||||
case "flac":// Xiami/Raw Flac
|
case 'flac': // Xiami/Raw Flac
|
||||||
case "m4a":// Xiami/Raw M4a
|
case 'm4a': // Xiami/Raw M4a
|
||||||
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break;
|
break;
|
||||||
case "ogg":// Raw Ogg
|
case 'ogg': // Raw Ogg
|
||||||
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break;
|
break;
|
||||||
case "tm0":// QQ Music IOS Mp3
|
case 'tm0': // QQ Music IOS Mp3
|
||||||
case "tm3":// QQ Music IOS Mp3
|
case 'tm3': // QQ Music IOS Mp3
|
||||||
rt_data = await RawDecrypt(file.raw, raw.name, "mp3");
|
rt_data = await RawDecrypt(file.raw, raw.name, 'mp3');
|
||||||
break;
|
break;
|
||||||
case "qmc3"://QQ Music Android Mp3
|
case 'qmc3': //QQ Music Android Mp3
|
||||||
case "qmc2"://QQ Music Android Ogg
|
case 'qmc2': //QQ Music Android Ogg
|
||||||
case "qmc0"://QQ Music Android Mp3
|
case 'qmc0': //QQ Music Android Mp3
|
||||||
case "qmcflac"://QQ Music Android Flac
|
case 'qmcflac': //QQ Music Android Flac
|
||||||
case "qmcogg"://QQ Music Android Ogg
|
case 'qmcogg': //QQ Music Android Ogg
|
||||||
case "tkm"://QQ Music Accompaniment M4a
|
case 'tkm': //QQ Music Accompaniment M4a
|
||||||
case "bkcmp3"://Moo Music Mp3
|
case 'bkcmp3': //Moo Music Mp3
|
||||||
case "bkcflac"://Moo Music Flac
|
case 'bkcflac': //Moo Music Flac
|
||||||
case "mflac"://QQ Music New Flac
|
case 'mflac': //QQ Music New Flac
|
||||||
case "mflac0"://QQ Music New Flac
|
case 'mflac0': //QQ Music New Flac
|
||||||
case "mgg": //QQ Music New Ogg
|
case 'mgg': //QQ Music New Ogg
|
||||||
case "mgg1": //QQ Music New Ogg
|
case 'mgg1': //QQ Music New Ogg
|
||||||
case "666c6163"://QQ Music Weiyun Flac
|
case '666c6163': //QQ Music Weiyun Flac
|
||||||
case "6d7033"://QQ Music Weiyun Mp3
|
case '6d7033': //QQ Music Weiyun Mp3
|
||||||
case "6f6767"://QQ Music Weiyun Ogg
|
case '6f6767': //QQ Music Weiyun Ogg
|
||||||
case "6d3461"://QQ Music Weiyun M4a
|
case '6d3461': //QQ Music Weiyun M4a
|
||||||
case "776176"://QQ Music Weiyun Wav
|
case '776176': //QQ Music Weiyun Wav
|
||||||
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break;
|
break;
|
||||||
case "tm2":// QQ Music IOS M4a
|
case 'tm2': // QQ Music IOS M4a
|
||||||
case "tm6":// QQ Music IOS M4a
|
case 'tm6': // QQ Music IOS M4a
|
||||||
rt_data = await TmDecrypt(file.raw, raw.name);
|
rt_data = await TmDecrypt(file.raw, raw.name);
|
||||||
break;
|
break;
|
||||||
case "cache"://QQ Music Cache
|
case 'cache': //QQ Music Cache
|
||||||
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break;
|
break;
|
||||||
case "vpr":
|
case 'vpr':
|
||||||
case "kgm":
|
case 'kgm':
|
||||||
case "kgma":
|
case 'kgma':
|
||||||
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
|
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
throw "不支持此文件格式"
|
throw '不支持此文件格式';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
|
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
|
||||||
@ -78,4 +77,3 @@ export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
|||||||
console.log(rt_data);
|
console.log(rt_data);
|
||||||
return rt_data;
|
return rt_data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,26 +1,25 @@
|
|||||||
export interface DecryptResult {
|
export interface DecryptResult {
|
||||||
title: string
|
title: string;
|
||||||
album?: string
|
album?: string;
|
||||||
artist?: string
|
artist?: string;
|
||||||
|
|
||||||
mime: string
|
mime: string;
|
||||||
ext: string
|
ext: string;
|
||||||
|
|
||||||
file: string
|
file: string;
|
||||||
blob: Blob
|
blob: Blob;
|
||||||
picture?: string
|
picture?: string;
|
||||||
|
|
||||||
message?: string
|
|
||||||
rawExt?: string
|
|
||||||
rawFilename?: string
|
|
||||||
|
|
||||||
|
message?: string;
|
||||||
|
rawExt?: string;
|
||||||
|
rawFilename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileInfo {
|
export interface FileInfo {
|
||||||
status: string
|
status: string;
|
||||||
name: string,
|
name: string;
|
||||||
size: number,
|
size: number;
|
||||||
percentage: number,
|
percentage: number;
|
||||||
uid: number,
|
uid: number;
|
||||||
raw: File
|
raw: File;
|
||||||
}
|
}
|
||||||
|
@ -4,64 +4,68 @@ import {
|
|||||||
GetArrayBuffer,
|
GetArrayBuffer,
|
||||||
GetCoverFromFile,
|
GetCoverFromFile,
|
||||||
GetMetaFromFile,
|
GetMetaFromFile,
|
||||||
SniffAudioExt
|
SniffAudioExt,
|
||||||
} 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 config from "@/../package.json"
|
import config from '@/../package.json';
|
||||||
|
|
||||||
|
//prettier-ignore
|
||||||
const VprHeader = [
|
const VprHeader = [
|
||||||
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
||||||
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31]
|
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31
|
||||||
|
]
|
||||||
|
//prettier-ignore
|
||||||
const KgmHeader = [
|
const KgmHeader = [
|
||||||
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
||||||
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14]
|
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14
|
||||||
|
]
|
||||||
|
//prettier-ignore
|
||||||
const VprMaskDiff = [
|
const VprMaskDiff = [
|
||||||
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
|
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
|
||||||
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
|
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
|
||||||
0x00]
|
0x00
|
||||||
|
]
|
||||||
|
|
||||||
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 = new Uint8Array(await GetArrayBuffer(file));
|
||||||
if (raw_ext === "vpr") {
|
if (raw_ext === 'vpr') {
|
||||||
if (!BytesHasPrefix(oriData, VprHeader)) throw Error("Not a valid vpr file!")
|
if (!BytesHasPrefix(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(oriData, KgmHeader)) throw Error('Not a valid kgm(a) file!');
|
||||||
}
|
}
|
||||||
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer)
|
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer);
|
||||||
let headerLen = bHeaderLen.getUint32(0, true)
|
let headerLen = bHeaderLen.getUint32(0, true);
|
||||||
|
|
||||||
let audioData = oriData.slice(headerLen)
|
let audioData = oriData.slice(headerLen);
|
||||||
let dataLen = audioData.length
|
let dataLen = audioData.length;
|
||||||
if (audioData.byteLength > 1 << 26) {
|
if (audioData.byteLength > 1 << 26) {
|
||||||
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
|
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁");
|
||||||
}
|
}
|
||||||
|
|
||||||
let key1 = new Uint8Array(17)
|
let key1 = new Uint8Array(17);
|
||||||
key1.set(oriData.slice(0x1c, 0x2c), 0)
|
key1.set(oriData.slice(0x1c, 0x2c), 0);
|
||||||
if (MaskV2.length === 0) {
|
if (MaskV2.length === 0) {
|
||||||
if (!await LoadMaskV2()) throw Error("加载Kgm/Vpr Mask数据失败")
|
if (!(await LoadMaskV2())) throw Error('加载Kgm/Vpr Mask数据失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < dataLen; i++) {
|
for (let i = 0; i < dataLen; i++) {
|
||||||
let med8 = key1[i % 17] ^ audioData[i]
|
let med8 = key1[i % 17] ^ audioData[i];
|
||||||
med8 ^= (med8 & 0xf) << 4
|
med8 ^= (med8 & 0xf) << 4;
|
||||||
|
|
||||||
let msk8 = GetMask(i)
|
let msk8 = GetMask(i);
|
||||||
msk8 ^= (msk8 & 0xf) << 4
|
msk8 ^= (msk8 & 0xf) << 4;
|
||||||
audioData[i] = med8 ^ msk8
|
audioData[i] = med8 ^ msk8;
|
||||||
}
|
}
|
||||||
if (raw_ext === "vpr") {
|
if (raw_ext === 'vpr') {
|
||||||
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17]
|
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = SniffAudioExt(audioData);
|
const ext = SniffAudioExt(audioData);
|
||||||
const mime = AudioMimeType[ext];
|
const mime = AudioMimeType[ext];
|
||||||
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.artist);
|
||||||
return {
|
return {
|
||||||
album: musicMeta.common.album,
|
album: musicMeta.common.album,
|
||||||
picture: GetCoverFromFile(musicMeta),
|
picture: GetCoverFromFile(musicMeta),
|
||||||
@ -70,53 +74,52 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
|
|||||||
ext,
|
ext,
|
||||||
mime,
|
mime,
|
||||||
title,
|
title,
|
||||||
artist
|
artist,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function GetMask(pos: number) {
|
function GetMask(pos: number) {
|
||||||
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4]
|
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4];
|
||||||
}
|
}
|
||||||
|
|
||||||
let MaskV2: Uint8Array = new Uint8Array(0);
|
let MaskV2: Uint8Array = new Uint8Array(0);
|
||||||
|
|
||||||
async function LoadMaskV2(): Promise<boolean> {
|
async function LoadMaskV2(): Promise<boolean> {
|
||||||
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`
|
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`;
|
||||||
if (["http:", "https:"].some(v => v == self.location.protocol)) {
|
if (['http:', 'https:'].some((v) => v == self.location.protocol)) {
|
||||||
if (!!self.document) {// using Web Worker
|
if (!!self.document) {
|
||||||
mask_url = "./static/kgm.mask"
|
// using Web Worker
|
||||||
} else {// using Main thread
|
mask_url = './static/kgm.mask';
|
||||||
mask_url = "../static/kgm.mask"
|
} else {
|
||||||
|
// using Main thread
|
||||||
|
mask_url = '../static/kgm.mask';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(mask_url, {method: "GET"})
|
const resp = await fetch(mask_url, { method: 'GET' });
|
||||||
MaskV2 = new Uint8Array(await resp.arrayBuffer());
|
MaskV2 = new Uint8Array(await resp.arrayBuffer());
|
||||||
return true
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//prettier-ignore
|
||||||
const MaskV2PreDef = [
|
const MaskV2PreDef = [
|
||||||
0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37,
|
0xb8, 0xd5, 0x3d, 0xb2, 0xe9, 0xaf, 0x78, 0x8c, 0x83, 0x33, 0x71, 0x51, 0x76, 0xa0, 0xcd, 0x37, 0x2f, 0x3e, 0x35,
|
||||||
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68,
|
0x8d, 0xa9, 0xbe, 0x98, 0xb7, 0xe7, 0x8c, 0x22, 0xce, 0x5a, 0x61, 0xdf, 0x68, 0x69, 0x89, 0xfe, 0xa5, 0xb6, 0xde,
|
||||||
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A,
|
0xa9, 0x77, 0xfc, 0xc8, 0xbd, 0xbd, 0xe5, 0x6d, 0x3e, 0x5a, 0x36, 0xef, 0x69, 0x4e, 0xbe, 0xe1, 0xe9, 0x66, 0x1c,
|
||||||
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B,
|
0xf3, 0xd9, 0x02, 0xb6, 0xf2, 0x12, 0x9b, 0x44, 0xd0, 0x6f, 0xb9, 0x35, 0x89, 0xb6, 0x46, 0x6d, 0x73, 0x82, 0x06,
|
||||||
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7,
|
0x69, 0xc1, 0xed, 0xd7, 0x85, 0xc2, 0x30, 0xdf, 0xa2, 0x62, 0xbe, 0x79, 0x2d, 0x62, 0x62, 0x3d, 0x0d, 0x7e, 0xbe,
|
||||||
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48,
|
0x48, 0x89, 0x23, 0x02, 0xa0, 0xe4, 0xd5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xfd, 0x16, 0x3a, 0x21, 0x3b, 0x16, 0x0f,
|
||||||
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B,
|
0xc3, 0xb2, 0xbb, 0xb3, 0xe2, 0xba, 0x3a, 0x3d, 0x13, 0xec, 0xf6, 0x01, 0x45, 0x84, 0xa5, 0x70, 0x0f, 0x93, 0x49,
|
||||||
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84,
|
0x0c, 0x64, 0xcd, 0x31, 0xd5, 0xcc, 0x4c, 0x07, 0x01, 0x9e, 0x00, 0x1a, 0x23, 0x90, 0xbf, 0x88, 0x1e, 0x3b, 0xab,
|
||||||
0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00,
|
0xa6, 0x3e, 0xc4, 0x73, 0x47, 0x10, 0x7e, 0x3b, 0x5e, 0xbc, 0xe3, 0x00, 0x84, 0xff, 0x09, 0xd4, 0xe0, 0x89, 0x0f,
|
||||||
0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B,
|
0x5b, 0x58, 0x70, 0x4f, 0xfb, 0x65, 0xd8, 0x5c, 0x53, 0x1b, 0xd3, 0xc8, 0xc6, 0xbf, 0xef, 0x98, 0xb0, 0x50, 0x4f,
|
||||||
0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB,
|
0x0f, 0xea, 0xe5, 0x83, 0x58, 0x8c, 0x28, 0x2c, 0x84, 0x67, 0xcd, 0xd0, 0x9e, 0x47, 0xdb, 0x27, 0x50, 0xca, 0xf4,
|
||||||
0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA,
|
0x63, 0x63, 0xe8, 0x97, 0x7f, 0x1b, 0x4b, 0x0c, 0xc2, 0xc1, 0x21, 0x4c, 0xcc, 0x58, 0xf5, 0x94, 0x52, 0xa3, 0xf3,
|
||||||
0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA,
|
0xd3, 0xe0, 0x68, 0xf4, 0x00, 0x23, 0xf3, 0x5e, 0x0a, 0x7b, 0x93, 0xdd, 0xab, 0x12, 0xb2, 0x13, 0xe8, 0x84, 0xd7,
|
||||||
0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5,
|
0xa7, 0x9f, 0x0f, 0x32, 0x4c, 0x55, 0x1d, 0x04, 0x36, 0x52, 0xdc, 0x03, 0xf3, 0xf9, 0x4e, 0x42, 0xe9, 0x3d, 0x61,
|
||||||
0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD,
|
0xef, 0x7c, 0xb6, 0xb3, 0x93, 0x50,
|
||||||
0xAB, 0x12, 0xB2, 0x13, 0xE8, 0x84, 0xD7, 0xA7, 0x9F, 0x0F, 0x32, 0x4C, 0x55, 0x1D, 0x04, 0x36,
|
];
|
||||||
0x52, 0xDC, 0x03, 0xF3, 0xF9, 0x4E, 0x42, 0xE9, 0x3D, 0x61, 0xEF, 0x7C, 0xB6, 0xB3, 0x93, 0x50,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
@ -4,42 +4,41 @@ import {
|
|||||||
GetArrayBuffer,
|
GetArrayBuffer,
|
||||||
GetCoverFromFile,
|
GetCoverFromFile,
|
||||||
GetMetaFromFile,
|
GetMetaFromFile,
|
||||||
SniffAudioExt
|
SniffAudioExt,
|
||||||
} from "@/decrypt/utils";
|
} from '@/decrypt/utils';
|
||||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
//prettier-ignore
|
||||||
const MagicHeader = [
|
const MagicHeader = [
|
||||||
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
||||||
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
|
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
|
||||||
]
|
]
|
||||||
const PreDefinedKey = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk"
|
const PreDefinedKey = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||||
if (!BytesHasPrefix(oriData, MagicHeader)) {
|
if (!BytesHasPrefix(oriData, MagicHeader)) {
|
||||||
if (SniffAudioExt(oriData) === "aac") {
|
if (SniffAudioExt(oriData) === 'aac') {
|
||||||
return await RawDecrypt(file, raw_filename, "aac", false)
|
return await RawDecrypt(file, raw_filename, 'aac', false);
|
||||||
}
|
}
|
||||||
throw Error("not a valid kwm file")
|
throw Error('not a valid kwm file');
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileKey = oriData.slice(0x18, 0x20)
|
let fileKey = oriData.slice(0x18, 0x20);
|
||||||
let mask = createMaskFromKey(fileKey)
|
let mask = createMaskFromKey(fileKey);
|
||||||
let audioData = oriData.slice(0x400);
|
let audioData = oriData.slice(0x400);
|
||||||
let lenAudioData = audioData.length;
|
let lenAudioData = audioData.length;
|
||||||
for (let cur = 0; cur < lenAudioData; ++cur)
|
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20];
|
||||||
audioData[cur] ^= mask[cur % 0x20];
|
|
||||||
|
|
||||||
|
|
||||||
const ext = SniffAudioExt(audioData);
|
const ext = SniffAudioExt(audioData);
|
||||||
const mime = AudioMimeType[ext];
|
const mime = AudioMimeType[ext];
|
||||||
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.artist);
|
||||||
return {
|
return {
|
||||||
album: musicMeta.common.album,
|
album: musicMeta.common.album,
|
||||||
picture: GetCoverFromFile(musicMeta),
|
picture: GetCoverFromFile(musicMeta),
|
||||||
@ -48,30 +47,28 @@ export async function Decrypt(file: File, raw_filename: string, _: string): Prom
|
|||||||
mime,
|
mime,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
ext
|
ext,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
|
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
|
||||||
let keyView = new DataView(keyBytes.buffer)
|
let keyView = new DataView(keyBytes.buffer);
|
||||||
let keyStr = keyView.getBigUint64(0, true).toString()
|
let keyStr = keyView.getBigUint64(0, true).toString();
|
||||||
let keyStrTrim = trimKey(keyStr)
|
let keyStrTrim = trimKey(keyStr);
|
||||||
let key = new Uint8Array(32)
|
let key = new Uint8Array(32);
|
||||||
for (let i = 0; i < 32; i++) {
|
for (let i = 0; i < 32; i++) {
|
||||||
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i)
|
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return key
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function trimKey(keyRaw: string): string {
|
function trimKey(keyRaw: string): string {
|
||||||
let lenRaw = keyRaw.length;
|
let lenRaw = keyRaw.length;
|
||||||
let out = keyRaw;
|
let out = keyRaw;
|
||||||
if (lenRaw > 32) {
|
if (lenRaw > 32) {
|
||||||
out = keyRaw.slice(0, 32)
|
out = keyRaw.slice(0, 32);
|
||||||
} else if (lenRaw < 32) {
|
} else if (lenRaw < 32) {
|
||||||
out = keyRaw.padEnd(32, keyRaw)
|
out = keyRaw.padEnd(32, keyRaw);
|
||||||
}
|
}
|
||||||
return out
|
return out;
|
||||||
}
|
}
|
||||||
|
@ -7,79 +7,75 @@ import {
|
|||||||
IMusicMeta,
|
IMusicMeta,
|
||||||
SniffAudioExt,
|
SniffAudioExt,
|
||||||
WriteMetaToFlac,
|
WriteMetaToFlac,
|
||||||
WriteMetaToMp3
|
WriteMetaToMp3,
|
||||||
} from "@/decrypt/utils";
|
} from '@/decrypt/utils';
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
import jimp from 'jimp';
|
import jimp from 'jimp';
|
||||||
|
|
||||||
import AES from "crypto-js/aes";
|
import AES from 'crypto-js/aes';
|
||||||
import PKCS7 from "crypto-js/pad-pkcs7";
|
import PKCS7 from 'crypto-js/pad-pkcs7';
|
||||||
import ModeECB from "crypto-js/mode-ecb";
|
import ModeECB from 'crypto-js/mode-ecb';
|
||||||
import WordArray from "crypto-js/lib-typedarrays";
|
import WordArray from 'crypto-js/lib-typedarrays';
|
||||||
import Base64 from "crypto-js/enc-base64";
|
import Base64 from 'crypto-js/enc-base64';
|
||||||
import EncUTF8 from "crypto-js/enc-utf8";
|
import EncUTF8 from 'crypto-js/enc-utf8';
|
||||||
import EncHex from "crypto-js/enc-hex";
|
import EncHex from 'crypto-js/enc-hex';
|
||||||
|
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
const CORE_KEY = EncHex.parse("687a4852416d736f356b496e62617857");
|
|
||||||
const META_KEY = EncHex.parse("2331346C6A6B5F215C5D2630553C2728");
|
|
||||||
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
|
|
||||||
|
|
||||||
|
const CORE_KEY = EncHex.parse('687a4852416d736f356b496e62617857');
|
||||||
|
const META_KEY = EncHex.parse('2331346C6A6B5F215C5D2630553C2728');
|
||||||
|
const MagicHeader = [0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d];
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||||
return (new NcmDecrypt(await GetArrayBuffer(file), raw_filename)).decrypt()
|
return new NcmDecrypt(await GetArrayBuffer(file), raw_filename).decrypt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface NcmMusicMeta {
|
interface NcmMusicMeta {
|
||||||
//musicId: number
|
//musicId: number
|
||||||
musicName?: string
|
musicName?: string;
|
||||||
artist?: Array<string | number>[]
|
artist?: Array<string | number>[];
|
||||||
format?: string
|
format?: string;
|
||||||
album?: string
|
album?: string;
|
||||||
albumPic?: string
|
albumPic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NcmDjMeta {
|
interface NcmDjMeta {
|
||||||
mainMusic: NcmMusicMeta
|
mainMusic: NcmMusicMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NcmDecrypt {
|
class NcmDecrypt {
|
||||||
raw: ArrayBuffer
|
raw: ArrayBuffer;
|
||||||
view: DataView
|
view: DataView;
|
||||||
offset: number = 0
|
offset: number = 0;
|
||||||
filename: string
|
filename: string;
|
||||||
format: string = ""
|
format: string = '';
|
||||||
mime: string = ""
|
mime: string = '';
|
||||||
audio?: Uint8Array
|
audio?: Uint8Array;
|
||||||
blob?: Blob
|
blob?: Blob;
|
||||||
oriMeta?: NcmMusicMeta
|
oriMeta?: NcmMusicMeta;
|
||||||
newMeta?: IMusicMeta
|
newMeta?: IMusicMeta;
|
||||||
image?: { mime: string, buffer: ArrayBuffer, url: string }
|
image?: { mime: string; buffer: ArrayBuffer; url: string };
|
||||||
|
|
||||||
constructor(buf: ArrayBuffer, filename: string) {
|
constructor(buf: ArrayBuffer, filename: string) {
|
||||||
const prefix = new Uint8Array(buf, 0, 8)
|
const prefix = new Uint8Array(buf, 0, 8);
|
||||||
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error("此ncm文件已损坏")
|
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error('此ncm文件已损坏');
|
||||||
this.offset = 10
|
this.offset = 10;
|
||||||
this.raw = buf
|
this.raw = buf;
|
||||||
this.view = new DataView(buf)
|
this.view = new DataView(buf);
|
||||||
this.filename = filename
|
this.filename = filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getKeyData(): Uint8Array {
|
_getKeyData(): Uint8Array {
|
||||||
const keyLen = this.view.getUint32(this.offset, true);
|
const keyLen = this.view.getUint32(this.offset, true);
|
||||||
this.offset += 4;
|
this.offset += 4;
|
||||||
const cipherText = new Uint8Array(this.raw, this.offset, keyLen)
|
const cipherText = new Uint8Array(this.raw, this.offset, keyLen).map((uint8) => uint8 ^ 0x64);
|
||||||
.map(uint8 => uint8 ^ 0x64);
|
|
||||||
this.offset += keyLen;
|
this.offset += keyLen;
|
||||||
|
|
||||||
const plainText = AES.decrypt(
|
const plainText = AES.decrypt(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
{ciphertext: WordArray.create(cipherText)},
|
{ ciphertext: WordArray.create(cipherText) },
|
||||||
CORE_KEY,
|
CORE_KEY,
|
||||||
{mode: ModeECB, padding: PKCS7}
|
{ mode: ModeECB, padding: PKCS7 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = new Uint8Array(plainText.sigBytes);
|
const result = new Uint8Array(plainText.sigBytes);
|
||||||
@ -90,11 +86,11 @@ class NcmDecrypt {
|
|||||||
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.slice(17)
|
return result.slice(17);
|
||||||
}
|
}
|
||||||
|
|
||||||
_getKeyBox(): Uint8Array {
|
_getKeyBox(): Uint8Array {
|
||||||
const keyData = this._getKeyData()
|
const keyData = this._getKeyData();
|
||||||
const box = new Uint8Array(Array(256).keys());
|
const box = new Uint8Array(Array(256).keys());
|
||||||
|
|
||||||
const keyDataLen = keyData.length;
|
const keyDataLen = keyData.length;
|
||||||
@ -119,126 +115,123 @@ class NcmDecrypt {
|
|||||||
this.offset += 4;
|
this.offset += 4;
|
||||||
if (metaDataLen === 0) return {};
|
if (metaDataLen === 0) return {};
|
||||||
|
|
||||||
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen)
|
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen).map((data) => data ^ 0x63);
|
||||||
.map(data => data ^ 0x63);
|
|
||||||
this.offset += metaDataLen;
|
this.offset += metaDataLen;
|
||||||
|
|
||||||
WordArray.create()
|
WordArray.create();
|
||||||
const plainText = AES.decrypt(
|
const plainText = AES.decrypt(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
{
|
{
|
||||||
ciphertext: Base64.parse(
|
ciphertext: Base64.parse(
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
WordArray.create(cipherText.slice(22)).toString(EncUTF8)
|
WordArray.create(cipherText.slice(22)).toString(EncUTF8),
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
META_KEY,
|
META_KEY,
|
||||||
{mode: ModeECB, padding: PKCS7}
|
{ mode: ModeECB, padding: PKCS7 },
|
||||||
).toString(EncUTF8);
|
).toString(EncUTF8);
|
||||||
|
|
||||||
const labelIndex = plainText.indexOf(":");
|
const labelIndex = plainText.indexOf(':');
|
||||||
let result: NcmMusicMeta;
|
let result: NcmMusicMeta;
|
||||||
if (plainText.slice(0, labelIndex) === "dj") {
|
if (plainText.slice(0, labelIndex) === 'dj') {
|
||||||
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
|
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
|
||||||
result = tmp.mainMusic;
|
result = tmp.mainMusic;
|
||||||
} else {
|
} else {
|
||||||
result = JSON.parse(plainText.slice(labelIndex + 1));
|
result = JSON.parse(plainText.slice(labelIndex + 1));
|
||||||
}
|
}
|
||||||
if (!!result.albumPic) {
|
if (!!result.albumPic) {
|
||||||
result.albumPic = result.albumPic.replace("http://", "https://") + "?param=500y500"
|
result.albumPic = result.albumPic.replace('http://', 'https://') + '?param=500y500';
|
||||||
}
|
}
|
||||||
return result
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getAudio(keyBox: Uint8Array): Uint8Array {
|
_getAudio(keyBox: Uint8Array): Uint8Array {
|
||||||
this.offset += this.view.getUint32(this.offset + 5, true) + 13
|
this.offset += this.view.getUint32(this.offset + 5, true) + 13;
|
||||||
const audioData = new Uint8Array(this.raw, this.offset)
|
const audioData = new Uint8Array(this.raw, this.offset);
|
||||||
let lenAudioData = audioData.length
|
let lenAudioData = audioData.length;
|
||||||
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff]
|
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff];
|
||||||
return audioData
|
return audioData;
|
||||||
}
|
}
|
||||||
|
|
||||||
async _buildMeta() {
|
async _buildMeta() {
|
||||||
if (!this.oriMeta) throw Error("invalid sequence")
|
if (!this.oriMeta) throw Error('invalid sequence');
|
||||||
|
|
||||||
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName)
|
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName);
|
||||||
|
|
||||||
// build artists
|
// build artists
|
||||||
let artists: string[] = [];
|
let artists: string[] = [];
|
||||||
if (!!this.oriMeta.artist) {
|
if (!!this.oriMeta.artist) {
|
||||||
this.oriMeta.artist.forEach(arr => artists.push(<string>arr[0]));
|
this.oriMeta.artist.forEach((arr) => artists.push(<string>arr[0]));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artists.length === 0 && !!info.artist) {
|
if (artists.length === 0 && !!info.artist) {
|
||||||
artists = info.artist.split(',')
|
artists = info.artist
|
||||||
.map(val => val.trim()).filter(val => val != "");
|
.split(',')
|
||||||
|
.map((val) => val.trim())
|
||||||
|
.filter((val) => val != '');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.oriMeta.albumPic) try {
|
if (this.oriMeta.albumPic)
|
||||||
this.image = await GetImageFromURL(this.oriMeta.albumPic)
|
try {
|
||||||
|
this.image = await GetImageFromURL(this.oriMeta.albumPic);
|
||||||
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
|
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
|
||||||
let img = await jimp.read(Buffer.from(this.image.buffer))
|
let img = await jimp.read(Buffer.from(this.image.buffer));
|
||||||
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO)
|
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO);
|
||||||
this.image.buffer = await img.getBufferAsync("image/jpeg")
|
this.image.buffer = await img.getBufferAsync('image/jpeg');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("get cover image failed", e)
|
console.log('get cover image failed', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.newMeta = { title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer };
|
||||||
this.newMeta = {title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async _writeMeta() {
|
async _writeMeta() {
|
||||||
if (!this.audio || !this.newMeta) throw Error("invalid sequence")
|
if (!this.audio || !this.newMeta) throw Error('invalid sequence');
|
||||||
|
|
||||||
if (!this.blob) this.blob = new Blob([this.audio], {type: this.mime})
|
if (!this.blob) this.blob = new Blob([this.audio], { type: this.mime });
|
||||||
const ori = await metaParseBlob(this.blob);
|
const ori = await metaParseBlob(this.blob);
|
||||||
|
|
||||||
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title
|
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title;
|
||||||
if (shouldWrite || this.newMeta.picture) {
|
if (shouldWrite || this.newMeta.picture) {
|
||||||
if (this.format === "mp3") {
|
if (this.format === 'mp3') {
|
||||||
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori)
|
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori);
|
||||||
} else if (this.format === "flac") {
|
} else if (this.format === 'flac') {
|
||||||
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori)
|
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori);
|
||||||
} else {
|
} else {
|
||||||
console.info(`writing meta for ${this.format} is not being supported for now`)
|
console.info(`writing meta for ${this.format} is not being supported for now`);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
this.blob = new Blob([this.audio], {type: this.mime})
|
this.blob = new Blob([this.audio], { type: this.mime });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
gatherResult(): DecryptResult {
|
gatherResult(): DecryptResult {
|
||||||
if (!this.newMeta || !this.blob) throw Error("bad sequence")
|
if (!this.newMeta || !this.blob) throw Error('bad sequence');
|
||||||
return {
|
return {
|
||||||
title: this.newMeta.title,
|
title: this.newMeta.title,
|
||||||
artist: this.newMeta.artists?.join("; "),
|
artist: this.newMeta.artists?.join('; '),
|
||||||
ext: this.format,
|
ext: this.format,
|
||||||
album: this.newMeta.album,
|
album: this.newMeta.album,
|
||||||
picture: this.image?.url,
|
picture: this.image?.url,
|
||||||
file: URL.createObjectURL(this.blob),
|
file: URL.createObjectURL(this.blob),
|
||||||
blob: this.blob,
|
blob: this.blob,
|
||||||
mime: this.mime
|
mime: this.mime,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async decrypt() {
|
async decrypt() {
|
||||||
const keyBox = this._getKeyBox()
|
const keyBox = this._getKeyBox();
|
||||||
this.oriMeta = this._getMetaData()
|
this.oriMeta = this._getMetaData();
|
||||||
this.audio = this._getAudio(keyBox)
|
this.audio = this._getAudio(keyBox);
|
||||||
this.format = this.oriMeta.format || SniffAudioExt(this.audio)
|
this.format = this.oriMeta.format || SniffAudioExt(this.audio);
|
||||||
this.mime = AudioMimeType[this.format]
|
this.mime = AudioMimeType[this.format];
|
||||||
await this._buildMeta()
|
await this._buildMeta();
|
||||||
try {
|
try {
|
||||||
await this._writeMeta()
|
await this._writeMeta();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("write meta data failed", e)
|
console.warn('write meta data failed', e);
|
||||||
}
|
}
|
||||||
return this.gatherResult()
|
return this.gatherResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,20 +1,19 @@
|
|||||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils";
|
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils';
|
||||||
|
|
||||||
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, raw_ext: string)
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
: Promise<DecryptResult> {
|
|
||||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
let length = buffer.length
|
let length = buffer.length;
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
buffer[i] ^= 163
|
buffer[i] ^= 163;
|
||||||
}
|
}
|
||||||
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.artist);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@ -24,6 +23,6 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
picture: GetCoverFromFile(tag),
|
picture: GetCoverFromFile(tag),
|
||||||
file: URL.createObjectURL(file),
|
file: URL.createObjectURL(file),
|
||||||
blob: file,
|
blob: file,
|
||||||
mime: AudioMimeType[ext]
|
mime: AudioMimeType[ext],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import fs from "fs";
|
import fs from 'fs';
|
||||||
import {QmcDecoder} from "@/decrypt/qmc";
|
import { QmcDecoder } from '@/decrypt/qmc';
|
||||||
import {BytesEqual} from "@/decrypt/utils";
|
import { BytesEqual } from '@/decrypt/utils';
|
||||||
|
|
||||||
function loadTestDataDecoder(name: string): {
|
function loadTestDataDecoder(name: string): {
|
||||||
cipherText: Uint8Array,
|
cipherText: Uint8Array;
|
||||||
clearText: Uint8Array
|
clearText: Uint8Array;
|
||||||
} {
|
} {
|
||||||
const cipherBody = fs.readFileSync(`./testdata/${name}_raw.bin`);
|
const cipherBody = fs.readFileSync(`./testdata/${name}_raw.bin`);
|
||||||
const cipherSuffix = fs.readFileSync(`./testdata/${name}_suffix.bin`);
|
const cipherSuffix = fs.readFileSync(`./testdata/${name}_suffix.bin`);
|
||||||
@ -13,20 +13,17 @@ function loadTestDataDecoder(name: string): {
|
|||||||
cipherText.set(cipherSuffix, cipherBody.length);
|
cipherText.set(cipherSuffix, cipherBody.length);
|
||||||
return {
|
return {
|
||||||
cipherText,
|
cipherText,
|
||||||
clearText: fs.readFileSync(`testdata/${name}_target.bin`)
|
clearText: fs.readFileSync(`testdata/${name}_target.bin`),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("qmc: real file", async () => {
|
test('qmc: real file', async () => {
|
||||||
const cases = ["mflac0_rc4", "mflac_map", "mgg_map", "qmc0_static"]
|
const cases = ['mflac0_rc4', 'mflac_map', 'mgg_map', 'qmc0_static'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {clearText, cipherText} = loadTestDataDecoder(name)
|
const { clearText, cipherText } = loadTestDataDecoder(name);
|
||||||
const c = new QmcDecoder(cipherText)
|
const c = new QmcDecoder(cipherText);
|
||||||
const buf = c.decrypt()
|
const buf = c.decrypt();
|
||||||
|
|
||||||
expect(BytesEqual(buf, clearText)).toBeTruthy()
|
expect(BytesEqual(buf, clearText)).toBeTruthy();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher} from "./qmc_cipher";
|
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
|
||||||
import {
|
import {
|
||||||
AudioMimeType,
|
AudioMimeType,
|
||||||
GetArrayBuffer,
|
GetArrayBuffer,
|
||||||
@ -7,56 +7,55 @@ import {
|
|||||||
GetMetaFromFile,
|
GetMetaFromFile,
|
||||||
SniffAudioExt,
|
SniffAudioExt,
|
||||||
WriteMetaToFlac,
|
WriteMetaToFlac,
|
||||||
WriteMetaToMp3
|
WriteMetaToMp3,
|
||||||
} from "@/decrypt/utils";
|
} from '@/decrypt/utils';
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
import {DecryptQMCWasm} from "./qmc_wasm";
|
import { DecryptQMCWasm } from './qmc_wasm';
|
||||||
|
|
||||||
|
import iconv from 'iconv-lite';
|
||||||
import iconv from "iconv-lite";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { queryAlbumCover } from '@/utils/api';
|
||||||
import {queryAlbumCover} from "@/utils/api";
|
import { QmcDeriveKey } from '@/decrypt/qmc_key';
|
||||||
import {QmcDeriveKey} from "@/decrypt/qmc_key";
|
|
||||||
|
|
||||||
interface Handler {
|
interface Handler {
|
||||||
ext: string
|
ext: string;
|
||||||
version: number
|
version: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HandlerMap: { [key: string]: Handler } = {
|
export const HandlerMap: { [key: string]: Handler } = {
|
||||||
"mgg": {ext: "ogg", version: 2},
|
mgg: { ext: 'ogg', version: 2 },
|
||||||
"mgg1": {ext: "ogg", version: 2},
|
mgg1: { ext: 'ogg', version: 2 },
|
||||||
"mflac": {ext: "flac", version: 2},
|
mflac: { ext: 'flac', version: 2 },
|
||||||
"mflac0": {ext: "flac", version: 2},
|
mflac0: { ext: 'flac', version: 2 },
|
||||||
|
|
||||||
// qmcflac / qmcogg:
|
// qmcflac / qmcogg:
|
||||||
// 有可能是 v2 加密但混用同一个后缀名。
|
// 有可能是 v2 加密但混用同一个后缀名。
|
||||||
"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: 1 },
|
||||||
"qmc2": {ext: "ogg", version: 1},
|
qmc2: { ext: 'ogg', version: 1 },
|
||||||
"qmc3": {ext: "mp3", version: 1},
|
qmc3: { ext: 'mp3', version: 1 },
|
||||||
"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 },
|
||||||
"666c6163": {ext: "flac", version: 1},
|
'666c6163': { ext: 'flac', version: 1 },
|
||||||
"6d7033": {ext: "mp3", version: 1},
|
'6d7033': { ext: 'mp3', version: 1 },
|
||||||
"6f6767": {ext: "ogg", version: 1},
|
'6f6767': { ext: 'ogg', version: 1 },
|
||||||
"6d3461": {ext: "m4a", version: 1},
|
'6d3461': { ext: 'm4a', version: 1 },
|
||||||
"776176": {ext: "wav", version: 1}
|
'776176': { ext: 'wav', version: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
|
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
|
||||||
const handler = HandlerMap[raw_ext];
|
const handler = HandlerMap[raw_ext];
|
||||||
let {version} = handler;
|
let { version } = handler;
|
||||||
|
|
||||||
const fileBuffer = await GetArrayBuffer(file);
|
const fileBuffer = await GetArrayBuffer(file);
|
||||||
let musicDecoded: Uint8Array | undefined;
|
let musicDecoded: Uint8Array | undefined;
|
||||||
|
|
||||||
if (version === 2 && globalThis.WebAssembly) {
|
if (version === 2 && globalThis.WebAssembly) {
|
||||||
console.log("qmc: using wasm decoder")
|
console.log('qmc: using wasm decoder');
|
||||||
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
|
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
|
||||||
// 如果 v2 检测失败,降级到 v1 再尝试一次
|
// 如果 v2 检测失败,降级到 v1 再尝试一次
|
||||||
if (v2Decrypted) {
|
if (v2Decrypted) {
|
||||||
@ -65,28 +64,28 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
}
|
}
|
||||||
if (!musicDecoded) {
|
if (!musicDecoded) {
|
||||||
// may throw error
|
// may throw error
|
||||||
console.log("qmc: using js decoder")
|
console.log('qmc: using js decoder');
|
||||||
const d = new QmcDecoder(new Uint8Array(fileBuffer))
|
const d = new QmcDecoder(new Uint8Array(fileBuffer));
|
||||||
musicDecoded = d.decrypt()
|
musicDecoded = d.decrypt();
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
||||||
const mime = AudioMimeType[ext];
|
const mime = AudioMimeType[ext];
|
||||||
|
|
||||||
let musicBlob = new Blob([musicDecoded], {type: mime});
|
let musicBlob = new Blob([musicDecoded], { type: mime });
|
||||||
|
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
for (let metaIdx in musicMeta.native) {
|
for (let metaIdx in musicMeta.native) {
|
||||||
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 = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk');
|
||||||
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
|
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
|
||||||
|
|
||||||
let imgUrl = GetCoverFromFile(musicMeta);
|
let imgUrl = GetCoverFromFile(musicMeta);
|
||||||
if (!imgUrl) {
|
if (!imgUrl) {
|
||||||
@ -94,20 +93,20 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
if (imgUrl) {
|
if (imgUrl) {
|
||||||
const imageInfo = await GetImageFromURL(imgUrl);
|
const imageInfo = await GetImageFromURL(imgUrl);
|
||||||
if (imageInfo) {
|
if (imageInfo) {
|
||||||
imgUrl = imageInfo.url
|
imgUrl = imageInfo.url;
|
||||||
try {
|
try {
|
||||||
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
|
const newMeta = { picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(' _ ') };
|
||||||
if (ext === "mp3") {
|
if (ext === 'mp3') {
|
||||||
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
|
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta);
|
||||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
musicBlob = new Blob([musicDecoded], { type: mime });
|
||||||
} else if (ext === 'flac') {
|
} else if (ext === 'flac') {
|
||||||
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
|
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta);
|
||||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
musicBlob = new Blob([musicDecoded], { type: mime });
|
||||||
} else {
|
} else {
|
||||||
console.info("writing metadata for " + ext + " is not being supported for now")
|
console.info('writing metadata for ' + ext + ' is not being supported for now');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Error while appending cover image to file " + e)
|
console.warn('Error while appending cover image to file ' + e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -120,86 +119,83 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
picture: imgUrl,
|
picture: imgUrl,
|
||||||
file: URL.createObjectURL(musicBlob),
|
file: URL.createObjectURL(musicBlob),
|
||||||
blob: musicBlob,
|
blob: musicBlob,
|
||||||
mime: mime
|
mime: mime,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
||||||
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
|
const song_query_url = 'https://stats.ixarea.com/apis' + '/music/qq-cover';
|
||||||
try {
|
try {
|
||||||
const data = await queryAlbumCover(title, artist, album)
|
const data = await queryAlbumCover(title, artist, album);
|
||||||
return `${song_query_url}/${data.Type}/${data.Id}`
|
return `${song_query_url}/${data.Type}/${data.Id}`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e);
|
console.warn(e);
|
||||||
}
|
}
|
||||||
return ""
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QmcDecoder {
|
export class QmcDecoder {
|
||||||
file: Uint8Array
|
private static readonly BYTE_COMMA = ','.charCodeAt(0);
|
||||||
size: number
|
file: Uint8Array;
|
||||||
decoded: boolean = false
|
size: number;
|
||||||
audioSize?: number
|
decoded: boolean = false;
|
||||||
private static readonly BYTE_COMMA = ','.charCodeAt(0)
|
audioSize?: number;
|
||||||
cipher?: QmcStreamCipher
|
cipher?: QmcStreamCipher;
|
||||||
|
|
||||||
constructor(file: Uint8Array) {
|
constructor(file: Uint8Array) {
|
||||||
this.file = file
|
this.file = file;
|
||||||
this.size = file.length
|
this.size = file.length;
|
||||||
this.searchKey()
|
this.searchKey();
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(): Uint8Array {
|
decrypt(): Uint8Array {
|
||||||
if (!this.cipher) {
|
if (!this.cipher) {
|
||||||
throw new Error("no cipher found")
|
throw new Error('no cipher found');
|
||||||
}
|
}
|
||||||
if (!this.audioSize || this.audioSize <= 0) {
|
if (!this.audioSize || this.audioSize <= 0) {
|
||||||
throw new Error("invalid audio size")
|
throw new Error('invalid audio size');
|
||||||
}
|
}
|
||||||
const audioBuf = this.file.subarray(0, this.audioSize)
|
const audioBuf = this.file.subarray(0, this.audioSize);
|
||||||
|
|
||||||
if (!this.decoded) {
|
if (!this.decoded) {
|
||||||
this.cipher.decrypt(audioBuf, 0)
|
this.cipher.decrypt(audioBuf, 0);
|
||||||
this.decoded = true
|
this.decoded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return audioBuf
|
return audioBuf;
|
||||||
}
|
}
|
||||||
|
|
||||||
private searchKey() {
|
private searchKey() {
|
||||||
const last4Byte = this.file.slice(-4);
|
const last4Byte = this.file.slice(-4);
|
||||||
const textEnc = new TextDecoder()
|
const textEnc = new TextDecoder();
|
||||||
if (textEnc.decode(last4Byte) === 'QTag') {
|
if (textEnc.decode(last4Byte) === 'QTag') {
|
||||||
const sizeBuf = this.file.slice(-8, -4)
|
const sizeBuf = this.file.slice(-8, -4);
|
||||||
const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset)
|
const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset);
|
||||||
const keySize = sizeView.getUint32(0, false)
|
const keySize = sizeView.getUint32(0, false);
|
||||||
this.audioSize = this.size - keySize - 8
|
this.audioSize = this.size - keySize - 8;
|
||||||
const rawKey = this.file.subarray(this.audioSize, this.size - 8)
|
const rawKey = this.file.subarray(this.audioSize, this.size - 8);
|
||||||
const keyEnd = rawKey.findIndex(v => v == QmcDecoder.BYTE_COMMA)
|
const keyEnd = rawKey.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
|
||||||
this.setCipher(rawKey.subarray(0, keyEnd))
|
this.setCipher(rawKey.subarray(0, keyEnd));
|
||||||
} 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 < 0x300) {
|
||||||
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);
|
||||||
} else {
|
} else {
|
||||||
this.audioSize = this.size
|
this.audioSize = this.size;
|
||||||
this.cipher = new QmcStaticCipher()
|
this.cipher = new QmcStaticCipher();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setCipher(keyRaw: Uint8Array) {
|
private setCipher(keyRaw: Uint8Array) {
|
||||||
const keyDec = QmcDeriveKey(keyRaw)
|
const keyDec = QmcDeriveKey(keyRaw);
|
||||||
if (keyDec.length > 300) {
|
if (keyDec.length > 300) {
|
||||||
this.cipher = new QmcRC4Cipher(keyDec)
|
this.cipher = new QmcRC4Cipher(keyDec);
|
||||||
} else {
|
} else {
|
||||||
this.cipher = new QmcMapCipher(keyDec)
|
this.cipher = new QmcMapCipher(keyDec);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,115 +1,117 @@
|
|||||||
import {QmcMapCipher, QmcRC4Cipher, QmcStaticCipher} from "@/decrypt/qmc_cipher";
|
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher } from '@/decrypt/qmc_cipher';
|
||||||
import fs from 'fs'
|
import fs from 'fs';
|
||||||
|
|
||||||
test("static cipher [0x7ff8,0x8000) ", () => {
|
test('static cipher [0x7ff8,0x8000) ', () => {
|
||||||
|
//prettier-ignore
|
||||||
const expected = new Uint8Array([
|
const expected = new Uint8Array([
|
||||||
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
|
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
|
||||||
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
|
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
|
||||||
])
|
])
|
||||||
|
|
||||||
const c = new QmcStaticCipher()
|
const c = new QmcStaticCipher();
|
||||||
const buf = new Uint8Array(16)
|
const buf = new Uint8Array(16);
|
||||||
c.decrypt(buf, 0x7ff8)
|
c.decrypt(buf, 0x7ff8);
|
||||||
|
|
||||||
expect(buf).toStrictEqual(expected)
|
expect(buf).toStrictEqual(expected);
|
||||||
})
|
});
|
||||||
|
|
||||||
test("static cipher [0,0x10) ", () => {
|
test('static cipher [0,0x10) ', () => {
|
||||||
|
//prettier-ignore
|
||||||
const expected = new Uint8Array([
|
const expected = new Uint8Array([
|
||||||
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
|
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
|
||||||
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
|
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
|
||||||
])
|
])
|
||||||
|
|
||||||
const c = new QmcStaticCipher()
|
const c = new QmcStaticCipher();
|
||||||
const buf = new Uint8Array(16)
|
const buf = new Uint8Array(16);
|
||||||
c.decrypt(buf, 0)
|
c.decrypt(buf, 0);
|
||||||
|
|
||||||
expect(buf).toStrictEqual(expected)
|
expect(buf).toStrictEqual(expected);
|
||||||
})
|
});
|
||||||
|
|
||||||
|
test('map cipher: get mask', () => {
|
||||||
test("map cipher: get mask", () => {
|
//prettier-ignore
|
||||||
const expected = new Uint8Array([
|
const expected = new Uint8Array([
|
||||||
0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB,
|
0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB,
|
||||||
0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79,
|
0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79,
|
||||||
])
|
])
|
||||||
const key = new Uint8Array(256)
|
const key = new Uint8Array(256);
|
||||||
for (let i = 0; i < 256; i++) key[i] = i
|
for (let i = 0; i < 256; i++) key[i] = i;
|
||||||
const buf = new Uint8Array(16)
|
const buf = new Uint8Array(16);
|
||||||
|
|
||||||
const c = new QmcMapCipher(key)
|
const c = new QmcMapCipher(key);
|
||||||
c.decrypt(buf, 0)
|
c.decrypt(buf, 0);
|
||||||
expect(buf).toStrictEqual(expected)
|
expect(buf).toStrictEqual(expected);
|
||||||
})
|
});
|
||||||
|
|
||||||
function loadTestDataCipher(name: string): {
|
function loadTestDataCipher(name: string): {
|
||||||
key: Uint8Array,
|
key: Uint8Array;
|
||||||
cipherText: Uint8Array,
|
cipherText: Uint8Array;
|
||||||
clearText: Uint8Array
|
clearText: Uint8Array;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
key: fs.readFileSync(`testdata/${name}_key.bin`),
|
key: fs.readFileSync(`testdata/${name}_key.bin`),
|
||||||
cipherText: fs.readFileSync(`testdata/${name}_raw.bin`),
|
cipherText: fs.readFileSync(`testdata/${name}_raw.bin`),
|
||||||
clearText: fs.readFileSync(`testdata/${name}_target.bin`)
|
clearText: fs.readFileSync(`testdata/${name}_target.bin`),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("map cipher: real file", async () => {
|
test('map cipher: real file', async () => {
|
||||||
const cases = ["mflac_map", "mgg_map"]
|
const cases = ['mflac_map', 'mgg_map'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {key, clearText, cipherText} = loadTestDataCipher(name)
|
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||||
const c = new QmcMapCipher(key)
|
const c = new QmcMapCipher(key);
|
||||||
|
|
||||||
c.decrypt(cipherText, 0)
|
c.decrypt(cipherText, 0);
|
||||||
|
|
||||||
expect(cipherText).toStrictEqual(clearText)
|
expect(cipherText).toStrictEqual(clearText);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test("rc4 cipher: real file", async () => {
|
test('rc4 cipher: real file', async () => {
|
||||||
const cases = ["mflac0_rc4"]
|
const cases = ['mflac0_rc4'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {key, clearText, cipherText} = loadTestDataCipher(name)
|
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||||
const c = new QmcRC4Cipher(key)
|
const c = new QmcRC4Cipher(key);
|
||||||
|
|
||||||
c.decrypt(cipherText, 0)
|
c.decrypt(cipherText, 0);
|
||||||
|
|
||||||
expect(cipherText).toStrictEqual(clearText)
|
expect(cipherText).toStrictEqual(clearText);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test("rc4 cipher: first segment", async () => {
|
test('rc4 cipher: first segment', async () => {
|
||||||
const cases = ["mflac0_rc4"]
|
const cases = ['mflac0_rc4'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {key, clearText, cipherText} = loadTestDataCipher(name)
|
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||||
const c = new QmcRC4Cipher(key)
|
const c = new QmcRC4Cipher(key);
|
||||||
|
|
||||||
const buf = cipherText.slice(0, 128)
|
const buf = cipherText.slice(0, 128);
|
||||||
c.decrypt(buf, 0)
|
c.decrypt(buf, 0);
|
||||||
expect(buf).toStrictEqual(clearText.slice(0, 128))
|
expect(buf).toStrictEqual(clearText.slice(0, 128));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test("rc4 cipher: align block (128~5120)", async () => {
|
test('rc4 cipher: align block (128~5120)', async () => {
|
||||||
const cases = ["mflac0_rc4"]
|
const cases = ['mflac0_rc4'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {key, clearText, cipherText} = loadTestDataCipher(name)
|
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||||
const c = new QmcRC4Cipher(key)
|
const c = new QmcRC4Cipher(key);
|
||||||
|
|
||||||
const buf = cipherText.slice(128, 5120)
|
const buf = cipherText.slice(128, 5120);
|
||||||
c.decrypt(buf, 128)
|
c.decrypt(buf, 128);
|
||||||
expect(buf).toStrictEqual(clearText.slice(128, 5120))
|
expect(buf).toStrictEqual(clearText.slice(128, 5120));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
test("rc4 cipher: simple block (5120~10240)", async () => {
|
test('rc4 cipher: simple block (5120~10240)', async () => {
|
||||||
const cases = ["mflac0_rc4"]
|
const cases = ['mflac0_rc4'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {key, clearText, cipherText} = loadTestDataCipher(name)
|
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||||
const c = new QmcRC4Cipher(key)
|
const c = new QmcRC4Cipher(key);
|
||||||
|
|
||||||
const buf = cipherText.slice(5120, 10240)
|
const buf = cipherText.slice(5120, 10240);
|
||||||
c.decrypt(buf, 5120)
|
c.decrypt(buf, 5120);
|
||||||
expect(buf).toStrictEqual(clearText.slice(5120, 10240))
|
expect(buf).toStrictEqual(clearText.slice(5120, 10240));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
export interface QmcStreamCipher {
|
export interface QmcStreamCipher {
|
||||||
decrypt(buf: Uint8Array, offset: number): void
|
decrypt(buf: Uint8Array, offset: number): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class QmcStaticCipher implements QmcStreamCipher {
|
export class QmcStaticCipher implements QmcStreamCipher {
|
||||||
|
//prettier-ignore
|
||||||
private static readonly staticCipherBox: Uint8Array = new Uint8Array([
|
private static readonly staticCipherBox: Uint8Array = new Uint8Array([
|
||||||
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
|
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
|
||||||
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
|
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
|
||||||
@ -40,26 +40,26 @@ export class QmcStaticCipher implements QmcStreamCipher {
|
|||||||
])
|
])
|
||||||
|
|
||||||
public getMask(offset: number) {
|
public getMask(offset: number) {
|
||||||
if (offset > 0x7FFF) offset %= 0x7FFF
|
if (offset > 0x7fff) offset %= 0x7fff;
|
||||||
return QmcStaticCipher.staticCipherBox[(offset * offset + 27) & 0xff]
|
return QmcStaticCipher.staticCipherBox[(offset * offset + 27) & 0xff];
|
||||||
}
|
}
|
||||||
|
|
||||||
public decrypt(buf: Uint8Array, offset: number) {
|
public decrypt(buf: Uint8Array, offset: number) {
|
||||||
for (let i = 0; i < buf.length; i++) {
|
for (let i = 0; i < buf.length; i++) {
|
||||||
buf[i] ^= this.getMask(offset + i)
|
buf[i] ^= this.getMask(offset + i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QmcMapCipher implements QmcStreamCipher {
|
export class QmcMapCipher implements QmcStreamCipher {
|
||||||
key: Uint8Array
|
key: Uint8Array;
|
||||||
n: number
|
n: number;
|
||||||
|
|
||||||
constructor(key: Uint8Array) {
|
constructor(key: Uint8Array) {
|
||||||
if (key.length == 0) throw Error("qmc/cipher_map: invalid key size")
|
if (key.length == 0) throw Error('qmc/cipher_map: invalid key size');
|
||||||
|
|
||||||
this.key = key
|
this.key = key;
|
||||||
this.n = key.length
|
this.n = key.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static rotate(value: number, bits: number) {
|
private static rotate(value: number, bits: number) {
|
||||||
@ -71,7 +71,7 @@ export class QmcMapCipher implements QmcStreamCipher {
|
|||||||
|
|
||||||
decrypt(buf: Uint8Array, offset: number): void {
|
decrypt(buf: Uint8Array, offset: number): void {
|
||||||
for (let i = 0; i < buf.length; i++) {
|
for (let i = 0; i < buf.length; i++) {
|
||||||
buf[i] ^= this.getMask(offset + i)
|
buf[i] ^= this.getMask(offset + i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,27 +79,26 @@ export class QmcMapCipher implements QmcStreamCipher {
|
|||||||
if (offset > 0x7fff) offset %= 0x7fff;
|
if (offset > 0x7fff) offset %= 0x7fff;
|
||||||
|
|
||||||
const idx = (offset * offset + 71214) % this.n;
|
const idx = (offset * offset + 71214) % this.n;
|
||||||
return QmcMapCipher.rotate(this.key[idx], idx & 0x7)
|
return QmcMapCipher.rotate(this.key[idx], idx & 0x7);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class QmcRC4Cipher implements QmcStreamCipher {
|
export class QmcRC4Cipher implements QmcStreamCipher {
|
||||||
private static readonly FIRST_SEGMENT_SIZE = 0x80;
|
private static readonly FIRST_SEGMENT_SIZE = 0x80;
|
||||||
private static readonly SEGMENT_SIZE = 5120
|
private static readonly SEGMENT_SIZE = 5120;
|
||||||
|
|
||||||
S: Uint8Array
|
S: Uint8Array;
|
||||||
N: number
|
N: number;
|
||||||
key: Uint8Array
|
key: Uint8Array;
|
||||||
hash: number
|
hash: number;
|
||||||
|
|
||||||
constructor(key: Uint8Array) {
|
constructor(key: Uint8Array) {
|
||||||
if (key.length == 0) {
|
if (key.length == 0) {
|
||||||
throw Error("invalid key size")
|
throw Error('invalid key size');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.key = key
|
this.key = key;
|
||||||
this.N = key.length
|
this.N = key.length;
|
||||||
|
|
||||||
// init seed box
|
// init seed box
|
||||||
this.S = new Uint8Array(this.N);
|
this.S = new Uint8Array(this.N);
|
||||||
@ -109,7 +108,7 @@ export class QmcRC4Cipher implements QmcStreamCipher {
|
|||||||
let j = 0;
|
let j = 0;
|
||||||
for (let i = 0; i < this.N; ++i) {
|
for (let i = 0; i < this.N; ++i) {
|
||||||
j = (this.S[i] + j + this.key[i % this.N]) % this.N;
|
j = (this.S[i] + j + this.key[i % this.N]) % this.N;
|
||||||
[this.S[i], this.S[j]] = [this.S[j], this.S[i]]
|
[this.S[i], this.S[j]] = [this.S[j], this.S[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
// init hash base
|
// init hash base
|
||||||
@ -125,7 +124,6 @@ export class QmcRC4Cipher implements QmcStreamCipher {
|
|||||||
|
|
||||||
this.hash = next_hash;
|
this.hash = next_hash;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(buf: Uint8Array, offset: number): void {
|
decrypt(buf: Uint8Array, offset: number): void {
|
||||||
@ -133,52 +131,50 @@ export class QmcRC4Cipher implements QmcStreamCipher {
|
|||||||
let processed = 0;
|
let processed = 0;
|
||||||
const postProcess = (len: number): boolean => {
|
const postProcess = (len: number): boolean => {
|
||||||
toProcess -= len;
|
toProcess -= len;
|
||||||
processed += len
|
processed += len;
|
||||||
offset += len
|
offset += len;
|
||||||
return toProcess == 0
|
return toProcess == 0;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Initial segment
|
// Initial segment
|
||||||
if (offset < QmcRC4Cipher.FIRST_SEGMENT_SIZE) {
|
if (offset < QmcRC4Cipher.FIRST_SEGMENT_SIZE) {
|
||||||
const len_segment = Math.min(buf.length, QmcRC4Cipher.FIRST_SEGMENT_SIZE - offset);
|
const len_segment = Math.min(buf.length, QmcRC4Cipher.FIRST_SEGMENT_SIZE - offset);
|
||||||
this.encFirstSegment(buf.subarray(0, len_segment), offset);
|
this.encFirstSegment(buf.subarray(0, len_segment), offset);
|
||||||
if (postProcess(len_segment)) return
|
if (postProcess(len_segment)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// align segment
|
// align segment
|
||||||
if (offset % QmcRC4Cipher.SEGMENT_SIZE != 0) {
|
if (offset % QmcRC4Cipher.SEGMENT_SIZE != 0) {
|
||||||
const len_segment = Math.min(QmcRC4Cipher.SEGMENT_SIZE - (offset % QmcRC4Cipher.SEGMENT_SIZE), toProcess);
|
const len_segment = Math.min(QmcRC4Cipher.SEGMENT_SIZE - (offset % QmcRC4Cipher.SEGMENT_SIZE), toProcess);
|
||||||
this.encASegment(buf.subarray(processed, processed + len_segment), offset);
|
this.encASegment(buf.subarray(processed, processed + len_segment), offset);
|
||||||
if (postProcess(len_segment)) return
|
if (postProcess(len_segment)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch process segments
|
// Batch process segments
|
||||||
while (toProcess > QmcRC4Cipher.SEGMENT_SIZE) {
|
while (toProcess > QmcRC4Cipher.SEGMENT_SIZE) {
|
||||||
this.encASegment(buf.subarray(processed, processed + QmcRC4Cipher.SEGMENT_SIZE), offset);
|
this.encASegment(buf.subarray(processed, processed + QmcRC4Cipher.SEGMENT_SIZE), offset);
|
||||||
postProcess(QmcRC4Cipher.SEGMENT_SIZE)
|
postProcess(QmcRC4Cipher.SEGMENT_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last segment (incomplete segment)
|
// Last segment (incomplete segment)
|
||||||
if (toProcess > 0) {
|
if (toProcess > 0) {
|
||||||
this.encASegment(buf.subarray(processed), offset);
|
this.encASegment(buf.subarray(processed), offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private encFirstSegment(buf: Uint8Array, offset: number) {
|
private encFirstSegment(buf: Uint8Array, offset: number) {
|
||||||
for (let i = 0; i < buf.length; i++) {
|
for (let i = 0; i < buf.length; i++) {
|
||||||
|
|
||||||
buf[i] ^= this.key[this.getSegmentKey(offset + i)];
|
buf[i] ^= this.key[this.getSegmentKey(offset + i)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private encASegment(buf: Uint8Array, offset: number) {
|
private encASegment(buf: Uint8Array, offset: number) {
|
||||||
// Initialise a new seed box
|
// Initialise a new seed box
|
||||||
const S = this.S.slice(0)
|
const S = this.S.slice(0);
|
||||||
|
|
||||||
// Calculate the number of bytes to skip.
|
// Calculate the number of bytes to skip.
|
||||||
// The initial "key" derived from segment id, plus the current offset.
|
// The initial "key" derived from segment id, plus the current offset.
|
||||||
const skipLen = (offset % QmcRC4Cipher.SEGMENT_SIZE) + this.getSegmentKey(offset / QmcRC4Cipher.SEGMENT_SIZE)
|
const skipLen = (offset % QmcRC4Cipher.SEGMENT_SIZE) + this.getSegmentKey(offset / QmcRC4Cipher.SEGMENT_SIZE);
|
||||||
|
|
||||||
// decrypt the block
|
// decrypt the block
|
||||||
let j = 0;
|
let j = 0;
|
||||||
@ -186,7 +182,7 @@ export class QmcRC4Cipher implements QmcStreamCipher {
|
|||||||
for (let i = -skipLen; i < buf.length; i++) {
|
for (let i = -skipLen; i < buf.length; i++) {
|
||||||
j = (j + 1) % this.N;
|
j = (j + 1) % this.N;
|
||||||
k = (S[j] + k) % this.N;
|
k = (S[j] + k) % this.N;
|
||||||
[S[k], S[j]] = [S[j], S[k]]
|
[S[k], S[j]] = [S[j], S[k]];
|
||||||
|
|
||||||
if (i >= 0) {
|
if (i >= 0) {
|
||||||
buf[i] ^= S[(S[j] + S[k]) % this.N];
|
buf[i] ^= S[(S[j] + S[k]) % this.N];
|
||||||
@ -195,8 +191,8 @@ export class QmcRC4Cipher implements QmcStreamCipher {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getSegmentKey(id: number): number {
|
private getSegmentKey(id: number): number {
|
||||||
const seed = this.key[id % this.N]
|
const seed = this.key[id % this.N];
|
||||||
const idx = (this.hash / ((id + 1) * seed) * 100.0) | 0;
|
const idx = ((this.hash / ((id + 1) * seed)) * 100.0) | 0;
|
||||||
return idx % this.N
|
return idx % this.N;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,30 +1,26 @@
|
|||||||
import {QmcDeriveKey, simpleMakeKey} from "@/decrypt/qmc_key";
|
import { QmcDeriveKey, simpleMakeKey } from '@/decrypt/qmc_key';
|
||||||
import fs from "fs";
|
import fs from 'fs';
|
||||||
|
|
||||||
test("key dec: make simple key", () => {
|
test('key dec: make simple key', () => {
|
||||||
expect(
|
expect(simpleMakeKey(106, 8)).toStrictEqual([0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b]);
|
||||||
simpleMakeKey(106, 8)
|
});
|
||||||
).toStrictEqual(
|
|
||||||
[0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b]
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
function loadTestDataKeyDecrypt(name: string): {
|
function loadTestDataKeyDecrypt(name: string): {
|
||||||
cipherText: Uint8Array,
|
cipherText: Uint8Array;
|
||||||
clearText: Uint8Array
|
clearText: Uint8Array;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`),
|
cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`),
|
||||||
clearText: fs.readFileSync(`testdata/${name}_key.bin`)
|
clearText: fs.readFileSync(`testdata/${name}_key.bin`),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("key dec: real file", async () => {
|
test('key dec: real file', async () => {
|
||||||
const cases = ["mflac_map", "mgg_map", "mflac0_rc4"]
|
const cases = ['mflac_map', 'mgg_map', 'mflac0_rc4'];
|
||||||
for (const name of cases) {
|
for (const name of cases) {
|
||||||
const {clearText, cipherText} = loadTestDataKeyDecrypt(name)
|
const { clearText, cipherText } = loadTestDataKeyDecrypt(name);
|
||||||
const buf = QmcDeriveKey(cipherText)
|
const buf = QmcDeriveKey(cipherText);
|
||||||
|
|
||||||
expect(buf).toStrictEqual(clearText)
|
expect(buf).toStrictEqual(clearText);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
@ -1,86 +1,83 @@
|
|||||||
import {TeaCipher} from "@/utils/tea";
|
import { TeaCipher } from '@/utils/tea';
|
||||||
|
|
||||||
const SALT_LEN = 2
|
const SALT_LEN = 2;
|
||||||
const ZERO_LEN = 7
|
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')
|
const 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
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++) {
|
||||||
teaKey[i << 1] = simpleKey[i];
|
teaKey[i << 1] = simpleKey[i];
|
||||||
teaKey[(i << 1) + 1] = rawDec[i];
|
teaKey[(i << 1) + 1] = rawDec[i];
|
||||||
}
|
}
|
||||||
const sub = decryptTencentTea(rawDec.subarray(8), teaKey)
|
const sub = decryptTencentTea(rawDec.subarray(8), teaKey);
|
||||||
rawDec.set(sub, 8)
|
rawDec.set(sub, 8);
|
||||||
return rawDec.subarray(0, 8 + sub.length)
|
return rawDec.subarray(0, 8 + sub.length);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// simpleMakeKey exported only for unit test
|
// simpleMakeKey exported only for unit test
|
||||||
export function simpleMakeKey(salt: number, length: number): number[] {
|
export function simpleMakeKey(salt: number, length: number): number[] {
|
||||||
const keyBuf: number[] = []
|
const keyBuf: number[] = [];
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
const tmp = Math.tan(salt + i * 0.1)
|
const tmp = Math.tan(salt + i * 0.1);
|
||||||
keyBuf[i] = 0xff & (Math.abs(tmp) * 100.0)
|
keyBuf[i] = 0xff & (Math.abs(tmp) * 100.0);
|
||||||
}
|
}
|
||||||
return keyBuf
|
return keyBuf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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');
|
||||||
}
|
}
|
||||||
if (inBuf.length < 16) {
|
if (inBuf.length < 16) {
|
||||||
throw Error("inBuf size too small")
|
throw Error('inBuf size too small');
|
||||||
}
|
}
|
||||||
|
|
||||||
const blk = new TeaCipher(key, 32)
|
const blk = new TeaCipher(key, 32);
|
||||||
|
|
||||||
const tmpBuf = new Uint8Array(8);
|
const tmpBuf = new Uint8Array(8);
|
||||||
const tmpView = new DataView(tmpBuf.buffer);
|
const tmpView = new DataView(tmpBuf.buffer);
|
||||||
|
|
||||||
blk.decrypt(tmpView, new DataView(inBuf.buffer, inBuf.byteOffset, 8))
|
blk.decrypt(tmpView, new DataView(inBuf.buffer, inBuf.byteOffset, 8));
|
||||||
|
|
||||||
const nPadLen = tmpBuf[0] & 0x7;//只要最低三位
|
const nPadLen = tmpBuf[0] & 0x7; //只要最低三位
|
||||||
/*密文格式:PadLen(1byte)+Padding(var,0-7byte)+Salt(2byte)+Body(var byte)+Zero(7byte)*/
|
/*密文格式:PadLen(1byte)+Padding(var,0-7byte)+Salt(2byte)+Body(var byte)+Zero(7byte)*/
|
||||||
const outLen = inBuf.length - 1 /*PadLen*/ - nPadLen - SALT_LEN - ZERO_LEN;
|
const outLen = inBuf.length - 1 /*PadLen*/ - nPadLen - SALT_LEN - ZERO_LEN;
|
||||||
const outBuf = new Uint8Array(outLen)
|
const outBuf = new Uint8Array(outLen);
|
||||||
|
|
||||||
let ivPrev = new Uint8Array(8);
|
let ivPrev = new Uint8Array(8);
|
||||||
let ivCur = inBuf.slice(0, 8); // init iv
|
let ivCur = inBuf.slice(0, 8); // init iv
|
||||||
let inBufPos = 8;
|
let inBufPos = 8;
|
||||||
|
|
||||||
|
|
||||||
// 跳过 Padding Len 和 Padding
|
// 跳过 Padding Len 和 Padding
|
||||||
let tmpIdx = 1 + nPadLen;
|
let tmpIdx = 1 + nPadLen;
|
||||||
|
|
||||||
// CBC IV 处理
|
// CBC IV 处理
|
||||||
const cryptBlock = () => {
|
const cryptBlock = () => {
|
||||||
ivPrev = ivCur;
|
ivPrev = ivCur;
|
||||||
ivCur = inBuf.slice(inBufPos, inBufPos + 8)
|
ivCur = inBuf.slice(inBufPos, inBufPos + 8);
|
||||||
for (let j = 0; j < 8; j++) {
|
for (let j = 0; j < 8; j++) {
|
||||||
tmpBuf[j] ^= ivCur[j]
|
tmpBuf[j] ^= ivCur[j];
|
||||||
}
|
}
|
||||||
blk.decrypt(tmpView, tmpView)
|
blk.decrypt(tmpView, tmpView);
|
||||||
inBufPos += 8;
|
inBufPos += 8;
|
||||||
tmpIdx = 0;
|
tmpIdx = 0;
|
||||||
}
|
};
|
||||||
|
|
||||||
// 跳过 Salt
|
// 跳过 Salt
|
||||||
for (let i = 1; i <= SALT_LEN;) {
|
for (let i = 1; i <= SALT_LEN; ) {
|
||||||
if (tmpIdx < 8) {
|
if (tmpIdx < 8) {
|
||||||
tmpIdx++;
|
tmpIdx++;
|
||||||
i++;
|
i++;
|
||||||
} else {
|
} else {
|
||||||
cryptBlock()
|
cryptBlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,19 +86,18 @@ function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array {
|
|||||||
while (outBufPos < outLen) {
|
while (outBufPos < outLen) {
|
||||||
if (tmpIdx < 8) {
|
if (tmpIdx < 8) {
|
||||||
outBuf[outBufPos] = tmpBuf[tmpIdx] ^ ivPrev[tmpIdx];
|
outBuf[outBufPos] = tmpBuf[tmpIdx] ^ ivPrev[tmpIdx];
|
||||||
outBufPos++
|
outBufPos++;
|
||||||
tmpIdx++;
|
tmpIdx++;
|
||||||
} else {
|
} else {
|
||||||
cryptBlock()
|
cryptBlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 校验Zero
|
// 校验Zero
|
||||||
for (let i = 1; i <= ZERO_LEN; i++) {
|
for (let i = 1; i <= ZERO_LEN; i++) {
|
||||||
if (tmpBuf[tmpIdx] != ivPrev[tmpIdx]) {
|
if (tmpBuf[tmpIdx] != ivPrev[tmpIdx]) {
|
||||||
throw Error("zero check failed")
|
throw Error('zero check failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return outBuf
|
return outBuf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,13 +8,13 @@ const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
|
|||||||
|
|
||||||
function MergeUint8Array(array: Uint8Array[]): Uint8Array {
|
function MergeUint8Array(array: Uint8Array[]): Uint8Array {
|
||||||
let length = 0;
|
let length = 0;
|
||||||
array.forEach(item => {
|
array.forEach((item) => {
|
||||||
length += item.length;
|
length += item.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
let mergedArray = new Uint8Array(length);
|
let mergedArray = new Uint8Array(length);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
array.forEach(item => {
|
array.forEach((item) => {
|
||||||
mergedArray.set(item, offset);
|
mergedArray.set(item, offset);
|
||||||
offset += item.length;
|
offset += item.length;
|
||||||
});
|
});
|
||||||
@ -42,16 +42,12 @@ export async function DecryptQMCWasm(mggBlob: ArrayBuffer) {
|
|||||||
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
|
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
|
||||||
|
|
||||||
// 进行检测
|
// 进行检测
|
||||||
const detectOK = QMCCrypto.detectKeyEndPosition(
|
const detectOK = QMCCrypto.detectKeyEndPosition(pDetectionResult, pDetectionBuf, detectionBuf.length);
|
||||||
pDetectionResult,
|
|
||||||
pDetectionBuf,
|
|
||||||
detectionBuf.length
|
|
||||||
);
|
|
||||||
|
|
||||||
// 提取结构体内容:
|
// 提取结构体内容:
|
||||||
// (pos: i32; len: i32; error: char[??])
|
// (pos: i32; len: i32; error: char[??])
|
||||||
const position = QMCCrypto.getValue(pDetectionResult, "i32");
|
const position = QMCCrypto.getValue(pDetectionResult, 'i32');
|
||||||
const len = QMCCrypto.getValue(pDetectionResult + 4, "i32");
|
const len = QMCCrypto.getValue(pDetectionResult + 4, 'i32');
|
||||||
|
|
||||||
// 释放内存
|
// 释放内存
|
||||||
QMCCrypto._free(pDetectionBuf);
|
QMCCrypto._free(pDetectionBuf);
|
||||||
@ -66,9 +62,7 @@ export async function DecryptQMCWasm(mggBlob: ArrayBuffer) {
|
|||||||
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
|
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
|
||||||
|
|
||||||
// 提取嵌入到文件的 EKey
|
// 提取嵌入到文件的 EKey
|
||||||
const ekey = new Uint8Array(
|
const ekey = new Uint8Array(mggBlob.slice(decryptedSize, decryptedSize + len));
|
||||||
mggBlob.slice(decryptedSize, decryptedSize + len)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 解码 UTF-8 数据到 string
|
// 解码 UTF-8 数据到 string
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
@ -85,9 +79,7 @@ export async function DecryptQMCWasm(mggBlob: ArrayBuffer) {
|
|||||||
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
|
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
|
||||||
|
|
||||||
// 解密一些片段
|
// 解密一些片段
|
||||||
const blockData = new Uint8Array(
|
const blockData = new Uint8Array(mggBlob.slice(offset, offset + blockSize));
|
||||||
mggBlob.slice(offset, offset + blockSize)
|
|
||||||
);
|
|
||||||
QMCCrypto.writeArrayToMemory(blockData, buf);
|
QMCCrypto.writeArrayToMemory(blockData, buf);
|
||||||
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
|
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
|
||||||
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
|
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
|
||||||
|
@ -4,39 +4,38 @@ import {
|
|||||||
GetCoverFromFile,
|
GetCoverFromFile,
|
||||||
GetMetaFromFile,
|
GetMetaFromFile,
|
||||||
SniffAudioExt,
|
SniffAudioExt,
|
||||||
SplitFilename
|
SplitFilename,
|
||||||
} from "@/decrypt/utils";
|
} from '@/decrypt/utils';
|
||||||
|
|
||||||
import {Decrypt as QmcDecrypt, HandlerMap} from "@/decrypt/qmc";
|
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc';
|
||||||
|
|
||||||
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)
|
export async function Decrypt(file: Blob, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||||
: Promise<DecryptResult> {
|
|
||||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
let length = buffer.length
|
let length = buffer.length;
|
||||||
for (let i = 0; i < length; i++) {
|
for (let i = 0; i < length; i++) {
|
||||||
buffer[i] ^= 0xf4
|
buffer[i] ^= 0xf4;
|
||||||
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
|
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] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
|
||||||
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
|
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
|
||||||
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
|
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
|
||||||
}
|
}
|
||||||
let ext = SniffAudioExt(buffer, "");
|
let ext = SniffAudioExt(buffer, '');
|
||||||
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([buffer], { 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([buffer], { 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.artist);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@ -46,6 +45,6 @@ export async function Decrypt(file: Blob, raw_filename: string, _: string)
|
|||||||
picture: GetCoverFromFile(tag),
|
picture: GetCoverFromFile(tag),
|
||||||
file: URL.createObjectURL(audioBlob),
|
file: URL.createObjectURL(audioBlob),
|
||||||
blob: audioBlob,
|
blob: audioBlob,
|
||||||
mime: AudioMimeType[ext]
|
mime: AudioMimeType[ext],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,19 +1,23 @@
|
|||||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils";
|
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils';
|
||||||
|
|
||||||
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, raw_ext: string, detect: boolean = true)
|
export async function Decrypt(
|
||||||
: Promise<DecryptResult> {
|
file: Blob,
|
||||||
|
raw_filename: string,
|
||||||
|
raw_ext: string,
|
||||||
|
detect: boolean = true,
|
||||||
|
): Promise<DecryptResult> {
|
||||||
let ext = raw_ext;
|
let ext = raw_ext;
|
||||||
if (detect) {
|
if (detect) {
|
||||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
ext = SniffAudioExt(buffer, raw_ext);
|
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.artist);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@ -23,6 +27,6 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string,
|
|||||||
picture: GetCoverFromFile(tag),
|
picture: GetCoverFromFile(tag),
|
||||||
file: URL.createObjectURL(file),
|
file: URL.createObjectURL(file),
|
||||||
blob: file,
|
blob: file,
|
||||||
mime: AudioMimeType[ext]
|
mime: AudioMimeType[ext],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import {Decrypt as RawDecrypt} from "./raw";
|
import { Decrypt as RawDecrypt } from './raw';
|
||||||
import {GetArrayBuffer} from "@/decrypt/utils";
|
import { GetArrayBuffer } from '@/decrypt/utils';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
||||||
|
|
||||||
@ -9,6 +9,6 @@ export async function Decrypt(file: File, raw_filename: string): Promise<Decrypt
|
|||||||
for (let cur = 0; cur < 8; ++cur) {
|
for (let cur = 0; cur < 8; ++cur) {
|
||||||
audioData[cur] = TM_HEADER[cur];
|
audioData[cur] = TM_HEADER[cur];
|
||||||
}
|
}
|
||||||
const musicData = new Blob([audioData], {type: "audio/mp4"});
|
const musicData = new Blob([audioData], { type: 'audio/mp4' });
|
||||||
return await RawDecrypt(musicData, raw_filename, "m4a", false)
|
return await RawDecrypt(musicData, raw_filename, 'm4a', false);
|
||||||
}
|
}
|
||||||
|
@ -1,68 +1,64 @@
|
|||||||
import {IAudioMetadata} from "music-metadata-browser";
|
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 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];
|
||||||
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
|
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
|
||||||
export const WMA_HEADER = [
|
export const WMA_HEADER = [
|
||||||
0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11,
|
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c,
|
||||||
0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C,
|
];
|
||||||
]
|
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46];
|
||||||
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
|
export const AAC_HEADER = [0xff, 0xf1];
|
||||||
export const AAC_HEADER = [0xFF, 0xF1]
|
export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38];
|
||||||
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
|
|
||||||
|
|
||||||
export const AudioMimeType: { [key: string]: string } = {
|
export const AudioMimeType: { [key: string]: string } = {
|
||||||
mp3: "audio/mpeg",
|
mp3: 'audio/mpeg',
|
||||||
flac: "audio/flac",
|
flac: 'audio/flac',
|
||||||
m4a: "audio/mp4",
|
m4a: 'audio/mp4',
|
||||||
ogg: "audio/ogg",
|
ogg: 'audio/ogg',
|
||||||
wma: "audio/x-ms-wma",
|
wma: 'audio/x-ms-wma',
|
||||||
wav: "audio/x-wav",
|
wav: 'audio/x-wav',
|
||||||
dff: "audio/x-dff"
|
dff: 'audio/x-dff',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
|
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
|
||||||
if (prefix.length > data.length) return false
|
if (prefix.length > data.length) return false;
|
||||||
return prefix.every((val, idx) => {
|
return prefix.every((val, idx) => {
|
||||||
return val === data[idx];
|
return val === data[idx];
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BytesEqual(a: Uint8Array, b: Uint8Array,): boolean {
|
export function BytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
if (a.length !== b.length) return false
|
if (a.length !== b.length) return false;
|
||||||
return a.every((val, idx) => {
|
return a.every((val, idx) => {
|
||||||
return val === b[idx];
|
return val === b[idx];
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = 'mp3'): string {
|
||||||
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): string {
|
if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3';
|
||||||
if (BytesHasPrefix(data, MP3_HEADER)) return "mp3"
|
if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac';
|
||||||
if (BytesHasPrefix(data, FLAC_HEADER)) return "flac"
|
if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg';
|
||||||
if (BytesHasPrefix(data, OGG_HEADER)) return "ogg"
|
if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a';
|
||||||
if (data.length >= 4 + M4A_HEADER.length &&
|
if (BytesHasPrefix(data, WAV_HEADER)) return 'wav';
|
||||||
BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a"
|
if (BytesHasPrefix(data, WMA_HEADER)) return 'wma';
|
||||||
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
|
if (BytesHasPrefix(data, AAC_HEADER)) return 'aac';
|
||||||
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
|
if (BytesHasPrefix(data, DFF_HEADER)) return 'dff';
|
||||||
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
|
|
||||||
if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
|
|
||||||
return fallback_ext;
|
return fallback_ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
|
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
|
||||||
if (!!obj.arrayBuffer) return obj.arrayBuffer()
|
if (!!obj.arrayBuffer) return obj.arrayBuffer();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const rs = e.target?.result
|
const rs = e.target?.result;
|
||||||
if (!rs) {
|
if (!rs) {
|
||||||
reject("read file failed")
|
reject('read file failed');
|
||||||
} else {
|
} else {
|
||||||
resolve(rs as ArrayBuffer)
|
resolve(rs as ArrayBuffer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsArrayBuffer(obj);
|
reader.readAsArrayBuffer(obj);
|
||||||
@ -71,22 +67,25 @@ export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
|
|||||||
|
|
||||||
export function GetCoverFromFile(metadata: IAudioMetadata): string {
|
export function GetCoverFromFile(metadata: IAudioMetadata): string {
|
||||||
if (metadata.common?.picture && metadata.common.picture.length > 0) {
|
if (metadata.common?.picture && metadata.common.picture.length > 0) {
|
||||||
return URL.createObjectURL(new Blob(
|
return URL.createObjectURL(
|
||||||
[metadata.common.picture[0].data],
|
new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }),
|
||||||
{type: metadata.common.picture[0].format}
|
);
|
||||||
));
|
|
||||||
}
|
}
|
||||||
return "";
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMusicMetaBasic {
|
export interface IMusicMetaBasic {
|
||||||
title: string
|
title: string;
|
||||||
artist?: string
|
artist?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetMetaFromFile(filename: string, exist_title?: string, exist_artist?: string, separator = "-")
|
export function GetMetaFromFile(
|
||||||
: IMusicMetaBasic {
|
filename: string,
|
||||||
const meta: IMusicMetaBasic = {title: exist_title ?? "", artist: exist_artist}
|
exist_title?: string,
|
||||||
|
exist_artist?: string,
|
||||||
|
separator = '-',
|
||||||
|
): IMusicMetaBasic {
|
||||||
|
const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist };
|
||||||
|
|
||||||
const items = filename.split(separator);
|
const items = filename.split(separator);
|
||||||
if (items.length > 1) {
|
if (items.length > 1) {
|
||||||
@ -95,83 +94,83 @@ export function GetMetaFromFile(filename: string, exist_title?: string, exist_ar
|
|||||||
} 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();
|
||||||
}
|
}
|
||||||
return meta
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GetImageFromURL(src: string):
|
export async function GetImageFromURL(
|
||||||
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
|
src: string,
|
||||||
|
): Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(src);
|
const resp = await fetch(src);
|
||||||
const mime = resp.headers.get("Content-Type");
|
const mime = resp.headers.get('Content-Type');
|
||||||
if (mime?.startsWith("image/")) {
|
if (mime?.startsWith('image/')) {
|
||||||
const buffer = await resp.arrayBuffer();
|
const buffer = await resp.arrayBuffer();
|
||||||
const url = URL.createObjectURL(new Blob([buffer], {type: mime}))
|
const url = URL.createObjectURL(new Blob([buffer], { type: mime }));
|
||||||
return {buffer, url, mime}
|
return { buffer, url, mime };
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn(e)
|
console.warn(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface IMusicMeta {
|
export interface IMusicMeta {
|
||||||
title: string
|
title: string;
|
||||||
artists?: string[]
|
artists?: string[];
|
||||||
album?: string
|
album?: string;
|
||||||
picture?: ArrayBuffer
|
picture?: ArrayBuffer;
|
||||||
picture_desc?: string
|
picture_desc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||||
const writer = new ID3Writer(audioData);
|
const writer = new ID3Writer(audioData);
|
||||||
|
|
||||||
// reserve original data
|
// reserve original data
|
||||||
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []
|
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [];
|
||||||
frames.forEach(frame => {
|
frames.forEach((frame) => {
|
||||||
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
|
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
|
||||||
try {
|
try {
|
||||||
writer.setFrame(frame.id, frame.value)
|
writer.setFrame(frame.id, frame.value);
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
})
|
|
||||||
|
|
||||||
const old = original.common
|
const old = original.common;
|
||||||
writer.setFrame('TPE1', old?.artists || info.artists || [])
|
writer
|
||||||
|
.setFrame('TPE1', old?.artists || info.artists || [])
|
||||||
.setFrame('TIT2', old?.title || info.title)
|
.setFrame('TIT2', old?.title || info.title)
|
||||||
.setFrame('TALB', old?.album || info.album || "");
|
.setFrame('TALB', old?.album || info.album || '');
|
||||||
if (info.picture) {
|
if (info.picture) {
|
||||||
writer.setFrame('APIC', {
|
writer.setFrame('APIC', {
|
||||||
type: 3,
|
type: 3,
|
||||||
data: info.picture,
|
data: info.picture,
|
||||||
description: info.picture_desc || "Cover",
|
description: info.picture_desc || 'Cover',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return writer.addTag();
|
return writer.addTag();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||||
const writer = new MetaFlac(audioData)
|
const writer = new MetaFlac(audioData);
|
||||||
const old = original.common
|
const old = original.common;
|
||||||
if (!old.title && !old.album && old.artists) {
|
if (!old.title && !old.album && old.artists) {
|
||||||
writer.setTag("TITLE=" + info.title)
|
writer.setTag('TITLE=' + info.title);
|
||||||
writer.setTag("ALBUM=" + info.album)
|
writer.setTag('ALBUM=' + info.album);
|
||||||
if (info.artists) {
|
if (info.artists) {
|
||||||
writer.removeTag("ARTIST")
|
writer.removeTag('ARTIST');
|
||||||
info.artists.forEach(artist => writer.setTag("ARTIST=" + artist))
|
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (info.picture) {
|
if (info.picture) {
|
||||||
writer.importPictureFromBuffer(Buffer.from(info.picture))
|
writer.importPictureFromBuffer(Buffer.from(info.picture));
|
||||||
}
|
}
|
||||||
return writer.save()
|
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 {
|
||||||
ext: n.substring(pos + 1).toLowerCase(),
|
ext: n.substring(pos + 1).toLowerCase(),
|
||||||
name: n.substring(0, pos)
|
name: n.substring(0, pos),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,55 +1,57 @@
|
|||||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils";
|
import { AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile } from '@/decrypt/utils';
|
||||||
|
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
|
||||||
const MagicHeader = [0x69, 0x66, 0x6D, 0x74]
|
const MagicHeader = [0x69, 0x66, 0x6d, 0x74];
|
||||||
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]
|
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe];
|
||||||
const FileTypeMap: { [key: string]: string } = {
|
const FileTypeMap: { [key: string]: string } = {
|
||||||
" WAV": ".wav",
|
' WAV': '.wav',
|
||||||
"FLAC": ".flac",
|
FLAC: '.flac',
|
||||||
" MP3": ".mp3",
|
' MP3': '.mp3',
|
||||||
" A4M": ".m4a",
|
' A4M': '.m4a',
|
||||||
}
|
};
|
||||||
|
|
||||||
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 = new Uint8Array(await GetArrayBuffer(file));
|
||||||
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
|
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
|
||||||
if (raw_ext === "xm") {
|
if (raw_ext === 'xm') {
|
||||||
throw Error("此xm文件已损坏")
|
throw Error('此xm文件已损坏');
|
||||||
} else {
|
} else {
|
||||||
return await RawDecrypt(file, raw_filename, raw_ext, true)
|
return await RawDecrypt(file, raw_filename, raw_ext, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let typeText = (new TextDecoder()).decode(oriData.slice(4, 8))
|
let typeText = new TextDecoder().decode(oriData.slice(4, 8));
|
||||||
if (!FileTypeMap.hasOwnProperty(typeText)) {
|
if (!FileTypeMap.hasOwnProperty(typeText)) {
|
||||||
throw Error("未知的.xm文件类型")
|
throw Error('未知的.xm文件类型');
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = oriData[0xf]
|
let key = oriData[0xf];
|
||||||
let dataOffset = oriData[0xc] | oriData[0xd] << 8 | oriData[0xe] << 16
|
let dataOffset = oriData[0xc] | (oriData[0xd] << 8) | (oriData[0xe] << 16);
|
||||||
let audioData = oriData.slice(0x10);
|
let audioData = oriData.slice(0x10);
|
||||||
let lenAudioData = audioData.length;
|
let lenAudioData = audioData.length;
|
||||||
for (let cur = dataOffset; cur < lenAudioData; ++cur)
|
for (let cur = dataOffset; cur < lenAudioData; ++cur) audioData[cur] = (audioData[cur] - key) ^ 0xff;
|
||||||
audioData[cur] = (audioData[cur] - key) ^ 0xff;
|
|
||||||
|
|
||||||
const ext = FileTypeMap[typeText];
|
const ext = FileTypeMap[typeText];
|
||||||
const mime = AudioMimeType[ext];
|
const mime = AudioMimeType[ext];
|
||||||
let musicBlob = new Blob([audioData], {type: mime});
|
let musicBlob = new Blob([audioData], { type: mime });
|
||||||
|
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
if (ext === "wav") {
|
if (ext === 'wav') {
|
||||||
//todo:未知的编码方式
|
//todo:未知的编码方式
|
||||||
console.info(musicMeta.common)
|
console.info(musicMeta.common);
|
||||||
musicMeta.common.album = "";
|
musicMeta.common.album = '';
|
||||||
musicMeta.common.artist = "";
|
musicMeta.common.artist = '';
|
||||||
musicMeta.common.title = "";
|
musicMeta.common.title = '';
|
||||||
}
|
}
|
||||||
const {title, artist} = GetMetaFromFile(raw_filename,
|
const { title, artist } = GetMetaFromFile(
|
||||||
musicMeta.common.title, musicMeta.common.artist,
|
raw_filename,
|
||||||
raw_filename.indexOf("_") === -1 ? "-" : "_")
|
musicMeta.common.title,
|
||||||
|
musicMeta.common.artist,
|
||||||
|
raw_filename.indexOf('_') === -1 ? '-' : '_',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
@ -60,7 +62,6 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
|
|||||||
picture: GetCoverFromFile(musicMeta),
|
picture: GetCoverFromFile(musicMeta),
|
||||||
file: URL.createObjectURL(musicBlob),
|
file: URL.createObjectURL(musicBlob),
|
||||||
blob: musicBlob,
|
blob: musicBlob,
|
||||||
rawExt: "xm"
|
rawExt: 'xm',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,2 @@
|
|||||||
const bs = chrome || browser
|
const bs = chrome || browser;
|
||||||
bs.tabs.create({
|
bs.tabs.create({ url: bs.runtime.getURL('./index.html') }, (tab) => console.log(tab));
|
||||||
url: bs.runtime.getURL('./index.html')
|
|
||||||
}, tab => console.log(tab))
|
|
||||||
|
|
||||||
|
10
src/main.ts
10
src/main.ts
@ -1,6 +1,6 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue';
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue';
|
||||||
import '@/registerServiceWorker'
|
import '@/registerServiceWorker';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@ -19,7 +19,7 @@ import {
|
|||||||
TableColumn,
|
TableColumn,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Upload,
|
Upload,
|
||||||
MessageBox
|
MessageBox,
|
||||||
} from 'element-ui';
|
} from 'element-ui';
|
||||||
import 'element-ui/lib/theme-chalk/base.css';
|
import 'element-ui/lib/theme-chalk/base.css';
|
||||||
|
|
||||||
@ -44,5 +44,5 @@ Vue.prototype.$confirm = MessageBox.confirm;
|
|||||||
|
|
||||||
Vue.config.productionTip = false;
|
Vue.config.productionTip = false;
|
||||||
new Vue({
|
new Vue({
|
||||||
render: h => h(App),
|
render: (h) => h(App),
|
||||||
}).$mount('#app');
|
}).$mount('#app');
|
||||||
|
@ -1,31 +1,30 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import {register} from 'register-service-worker'
|
import { register } from 'register-service-worker';
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production' && window.location.protocol === "https:") {
|
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') {
|
||||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||||
ready() {
|
ready() {
|
||||||
console.log('App is being served from cache by a service worker.')
|
console.log('App is being served from cache by a service worker.');
|
||||||
},
|
},
|
||||||
registered() {
|
registered() {
|
||||||
console.log('Service worker has been registered.')
|
console.log('Service worker has been registered.');
|
||||||
},
|
},
|
||||||
cached() {
|
cached() {
|
||||||
console.log('Content has been cached for offline use.')
|
console.log('Content has been cached for offline use.');
|
||||||
},
|
},
|
||||||
updatefound() {
|
updatefound() {
|
||||||
console.log('New content is downloading.')
|
console.log('New content is downloading.');
|
||||||
},
|
},
|
||||||
updated() {
|
updated() {
|
||||||
console.log('New content is available.');
|
console.log('New content is available.');
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
},
|
},
|
||||||
offline() {
|
offline() {
|
||||||
console.log('No internet connection found. App is running in offline mode.')
|
console.log('No internet connection found. App is running in offline mode.');
|
||||||
},
|
},
|
||||||
error(error) {
|
error(error) {
|
||||||
console.error('Error during service worker registration:', error)
|
console.error('Error during service worker registration:', error);
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
22
src/shims-browser-id3-writer.d.ts
vendored
22
src/shims-browser-id3-writer.d.ts
vendored
@ -1,25 +1,23 @@
|
|||||||
declare module "browser-id3-writer" {
|
declare module 'browser-id3-writer' {
|
||||||
export default class ID3Writer {
|
export default class ID3Writer {
|
||||||
constructor(buffer: Buffer | ArrayBuffer)
|
constructor(buffer: Buffer | ArrayBuffer);
|
||||||
|
|
||||||
setFrame(name: string, value: string | object | string[])
|
setFrame(name: string, value: string | object | string[]);
|
||||||
|
|
||||||
addTag(): Uint8Array
|
addTag(): Uint8Array;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "metaflac-js" {
|
declare module 'metaflac-js' {
|
||||||
export default class Metaflac {
|
export default class Metaflac {
|
||||||
constructor(buffer: Buffer)
|
constructor(buffer: Buffer);
|
||||||
|
|
||||||
setTag(field: string)
|
setTag(field: string);
|
||||||
|
|
||||||
removeTag(name: string)
|
removeTag(name: string);
|
||||||
|
|
||||||
importPictureFromBuffer(picture: Buffer)
|
importPictureFromBuffer(picture: Buffer);
|
||||||
|
|
||||||
save(): Buffer
|
save(): Buffer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
42
src/shims-fs.d.ts
vendored
42
src/shims-fs.d.ts
vendored
@ -1,58 +1,54 @@
|
|||||||
export interface FileSystemGetFileOptions {
|
export interface FileSystemGetFileOptions {
|
||||||
create?: boolean
|
create?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileSystemCreateWritableOptions {
|
interface FileSystemCreateWritableOptions {
|
||||||
keepExistingData?: boolean
|
keepExistingData?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileSystemRemoveOptions {
|
interface FileSystemRemoveOptions {
|
||||||
recursive?: boolean
|
recursive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileSystemFileHandle {
|
interface FileSystemFileHandle {
|
||||||
getFile(): Promise<File>;
|
getFile(): Promise<File>;
|
||||||
|
|
||||||
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>
|
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WriteCommandType {
|
enum WriteCommandType {
|
||||||
write = "write",
|
write = 'write',
|
||||||
seek = "seek",
|
seek = 'seek',
|
||||||
truncate = "truncate",
|
truncate = 'truncate',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WriteParams {
|
interface WriteParams {
|
||||||
type: WriteCommandType
|
type: WriteCommandType;
|
||||||
size?: number
|
size?: number;
|
||||||
position?: number
|
position?: number;
|
||||||
data: BufferSource | Blob | string
|
data: BufferSource | Blob | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams
|
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams;
|
||||||
|
|
||||||
interface FileSystemWritableFileStream extends WritableStream {
|
interface FileSystemWritableFileStream extends WritableStream {
|
||||||
write(data: FileSystemWriteChunkType): Promise<undefined>
|
write(data: FileSystemWriteChunkType): Promise<undefined>;
|
||||||
|
|
||||||
seek(position: number): Promise<undefined>
|
seek(position: number): Promise<undefined>;
|
||||||
|
|
||||||
truncate(size: number): Promise<undefined>
|
truncate(size: number): Promise<undefined>;
|
||||||
|
|
||||||
close(): Promise<undefined> // should be implemented in WritableStream
|
close(): Promise<undefined>; // should be implemented in WritableStream
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export declare interface FileSystemDirectoryHandle {
|
export declare interface FileSystemDirectoryHandle {
|
||||||
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
|
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>;
|
||||||
|
|
||||||
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
|
|
||||||
|
|
||||||
|
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
|
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>;
|
||||||
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
10
src/shims-tsx.d.ts
vendored
10
src/shims-tsx.d.ts
vendored
@ -1,17 +1,15 @@
|
|||||||
import Vue, {VNode} from 'vue'
|
import Vue, { VNode } from 'vue';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
// tslint:disable no-empty-interface
|
// tslint:disable no-empty-interface
|
||||||
interface Element extends VNode {
|
interface Element extends VNode {}
|
||||||
}
|
|
||||||
|
|
||||||
// tslint:disable no-empty-interface
|
// tslint:disable no-empty-interface
|
||||||
interface ElementClass extends Vue {
|
interface ElementClass extends Vue {}
|
||||||
}
|
|
||||||
|
|
||||||
interface IntrinsicElements {
|
interface IntrinsicElements {
|
||||||
[elem: string]: any
|
[elem: string]: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
src/shims-vue.d.ts
vendored
4
src/shims-vue.d.ts
vendored
@ -1,4 +1,4 @@
|
|||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
import Vue from 'vue'
|
import Vue from 'vue';
|
||||||
export default Vue
|
export default Vue;
|
||||||
}
|
}
|
||||||
|
@ -1,56 +1,73 @@
|
|||||||
import {fromByteArray as Base64Encode} from "base64-js";
|
import { fromByteArray as Base64Encode } from 'base64-js';
|
||||||
|
|
||||||
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com"
|
export const IXAREA_API_ENDPOINT = 'https://um-api.ixarea.com';
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
Found: boolean
|
Found: boolean;
|
||||||
HttpsFound: boolean
|
HttpsFound: boolean;
|
||||||
Version: string
|
Version: string;
|
||||||
URL: string
|
URL: string;
|
||||||
Detail: string
|
Detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUpdate(version: string): Promise<UpdateInfo> {
|
export async function checkUpdate(version: string): Promise<UpdateInfo> {
|
||||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", {
|
const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({"Version": version})
|
body: JSON.stringify({ Version: version }),
|
||||||
});
|
});
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function reportKeyUsage(keyData: Uint8Array, maskData: number[], filename: string, format: string, title: string, artist?: string, album?: string) {
|
export function reportKeyUsage(
|
||||||
return fetch(IXAREA_API_ENDPOINT + "/qmcmask/usage", {
|
keyData: Uint8Array,
|
||||||
method: "POST",
|
maskData: number[],
|
||||||
headers: {"Content-Type": "application/json"},
|
filename: string,
|
||||||
|
format: string,
|
||||||
|
title: string,
|
||||||
|
artist?: string,
|
||||||
|
album?: string,
|
||||||
|
) {
|
||||||
|
return fetch(IXAREA_API_ENDPOINT + '/qmcmask/usage', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
Mask: Base64Encode(new Uint8Array(maskData)), Key: Base64Encode(keyData),
|
Mask: Base64Encode(new Uint8Array(maskData)),
|
||||||
Artist: artist, Title: title, Album: album, Filename: filename, Format: format
|
Key: Base64Encode(keyData),
|
||||||
|
Artist: artist,
|
||||||
|
Title: title,
|
||||||
|
Album: album,
|
||||||
|
Filename: filename,
|
||||||
|
Format: format,
|
||||||
}),
|
}),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyInfo {
|
interface KeyInfo {
|
||||||
Matrix44: string
|
Matrix44: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> {
|
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> {
|
||||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/qmcmask/query", {
|
const resp = await fetch(IXAREA_API_ENDPOINT + '/qmcmask/query', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44}),
|
body: JSON.stringify({ Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44 }),
|
||||||
});
|
});
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoverInfo {
|
export interface CoverInfo {
|
||||||
Id: string
|
Id: string;
|
||||||
Type: number
|
Type: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
|
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
|
||||||
const endpoint = IXAREA_API_ENDPOINT + "/music/qq-cover"
|
const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover';
|
||||||
const params = new URLSearchParams([["Title", title], ["Artist", artist ?? ""], ["Album", album ?? ""]])
|
const params = new URLSearchParams([
|
||||||
const resp = await fetch(`${endpoint}?${params.toString()}`)
|
['Title', title],
|
||||||
return await resp.json()
|
['Artist', artist ?? ''],
|
||||||
|
['Album', album ?? ''],
|
||||||
|
]);
|
||||||
|
const resp = await fetch(`${endpoint}?${params.toString()}`);
|
||||||
|
return await resp.json();
|
||||||
}
|
}
|
||||||
|
@ -4,74 +4,67 @@
|
|||||||
// Use of this source code is governed by a BSD-style
|
// Use of this source code is governed by a BSD-style
|
||||||
// license that can be found in https://go.dev/LICENSE.
|
// license that can be found in https://go.dev/LICENSE.
|
||||||
|
|
||||||
import {TeaCipher} from "@/utils/tea";
|
import { TeaCipher } from '@/utils/tea';
|
||||||
|
|
||||||
|
test('key size', () => {
|
||||||
test("key size", () => {
|
// prettier-ignore
|
||||||
const testKey = new Uint8Array([
|
const testKey = new Uint8Array([
|
||||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||||
0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
|
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
||||||
0x00
|
0x00,
|
||||||
])
|
])
|
||||||
expect(() => new TeaCipher(testKey.slice(0, 16)))
|
expect(() => new TeaCipher(testKey.slice(0, 16))).not.toThrow();
|
||||||
.not.toThrow()
|
|
||||||
|
|
||||||
expect(() => new TeaCipher(testKey))
|
expect(() => new TeaCipher(testKey)).toThrow();
|
||||||
.toThrow()
|
|
||||||
|
|
||||||
expect(() => new TeaCipher(testKey.slice(0, 15)))
|
|
||||||
.toThrow()
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
|
expect(() => new TeaCipher(testKey.slice(0, 15))).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
// prettier-ignore
|
||||||
const teaTests = [
|
const teaTests = [
|
||||||
// These were sourced from https://github.com/froydnj/ironclad/blob/master/testing/test-vectors/tea.testvec
|
// These were sourced from https://github.com/froydnj/ironclad/blob/master/testing/test-vectors/tea.testvec
|
||||||
{
|
{
|
||||||
rounds: TeaCipher.numRounds,
|
rounds: TeaCipher.numRounds,
|
||||||
key: new Uint8Array([
|
key: new Uint8Array([
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
]),
|
||||||
plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||||
cipherText: new Uint8Array([0x41, 0xea, 0x3a, 0x0a, 0x94, 0xba, 0xa9, 0x40]),
|
cipherText: new Uint8Array([0x41, 0xea, 0x3a, 0x0a, 0x94, 0xba, 0xa9, 0x40]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rounds: TeaCipher.numRounds,
|
rounds: TeaCipher.numRounds,
|
||||||
key: new Uint8Array([
|
key: new Uint8Array([
|
||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
|
]),
|
||||||
plainText: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
|
plainText: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
|
||||||
cipherText: new Uint8Array([0x31, 0x9b, 0xbe, 0xfb, 0x01, 0x6a, 0xbd, 0xb2]),
|
cipherText: new Uint8Array([0x31, 0x9b, 0xbe, 0xfb, 0x01, 0x6a, 0xbd, 0xb2]),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
rounds: 16,
|
rounds: 16,
|
||||||
key: new Uint8Array([
|
key: new Uint8Array([
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
]),
|
||||||
plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||||
cipherText: new Uint8Array([0xed, 0x28, 0x5d, 0xa1, 0x45, 0x5b, 0x33, 0xc1]),
|
cipherText: new Uint8Array([0xed, 0x28, 0x5d, 0xa1, 0x45, 0x5b, 0x33, 0xc1]),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
test("rounds", () => {
|
test('rounds', () => {
|
||||||
const tt = teaTests[0];
|
const tt = teaTests[0];
|
||||||
expect(() => new TeaCipher(tt.key, tt.rounds - 1))
|
expect(() => new TeaCipher(tt.key, tt.rounds - 1)).toThrow();
|
||||||
.toThrow()
|
});
|
||||||
})
|
|
||||||
|
|
||||||
|
test('encrypt & decrypt', () => {
|
||||||
test("encrypt & decrypt", () => {
|
|
||||||
for (const tt of teaTests) {
|
for (const tt of teaTests) {
|
||||||
const c = new TeaCipher(tt.key, tt.rounds)
|
const c = new TeaCipher(tt.key, tt.rounds);
|
||||||
|
|
||||||
const buf = new Uint8Array(8)
|
const buf = new Uint8Array(8);
|
||||||
const bufView = new DataView(buf.buffer)
|
const bufView = new DataView(buf.buffer);
|
||||||
|
|
||||||
c.encrypt(bufView, new DataView(tt.plainText.buffer))
|
c.encrypt(bufView, new DataView(tt.plainText.buffer));
|
||||||
expect(buf).toStrictEqual(tt.cipherText)
|
expect(buf).toStrictEqual(tt.cipherText);
|
||||||
|
|
||||||
c.decrypt(bufView, new DataView(tt.cipherText.buffer))
|
c.decrypt(bufView, new DataView(tt.cipherText.buffer));
|
||||||
expect(buf).toStrictEqual(tt.plainText)
|
expect(buf).toStrictEqual(tt.plainText);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
|
@ -27,56 +27,54 @@ export class TeaCipher {
|
|||||||
// numRounds 64 is the standard number of rounds in TEA.
|
// numRounds 64 is the standard number of rounds in TEA.
|
||||||
static readonly numRounds = 64;
|
static readonly numRounds = 64;
|
||||||
|
|
||||||
k0: number
|
k0: number;
|
||||||
k1: number
|
k1: number;
|
||||||
k2: number
|
k2: number;
|
||||||
k3: number
|
k3: number;
|
||||||
rounds: number
|
rounds: number;
|
||||||
|
|
||||||
constructor(key: Uint8Array, rounds: number = TeaCipher.numRounds) {
|
constructor(key: Uint8Array, rounds: number = TeaCipher.numRounds) {
|
||||||
if (key.length != 16) {
|
if (key.length != 16) {
|
||||||
throw Error("incorrect key size")
|
throw Error('incorrect key size');
|
||||||
}
|
}
|
||||||
if ((rounds & 1) != 0) {
|
if ((rounds & 1) != 0) {
|
||||||
throw Error("odd number of rounds specified")
|
throw Error('odd number of rounds specified');
|
||||||
}
|
}
|
||||||
|
|
||||||
const k = new DataView(key.buffer)
|
const k = new DataView(key.buffer);
|
||||||
this.k0 = k.getUint32(0, false)
|
this.k0 = k.getUint32(0, false);
|
||||||
this.k1 = k.getUint32(4, false)
|
this.k1 = k.getUint32(4, false);
|
||||||
this.k2 = k.getUint32(8, false)
|
this.k2 = k.getUint32(8, false);
|
||||||
this.k3 = k.getUint32(12, false)
|
this.k3 = k.getUint32(12, false);
|
||||||
this.rounds = rounds
|
this.rounds = rounds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
encrypt(dst: DataView, src: DataView) {
|
encrypt(dst: DataView, src: DataView) {
|
||||||
|
let v0 = src.getUint32(0, false);
|
||||||
|
let v1 = src.getUint32(4, false);
|
||||||
|
|
||||||
let v0 = src.getUint32(0, false)
|
let sum = 0;
|
||||||
let v1 = src.getUint32(4, false)
|
|
||||||
|
|
||||||
let sum = 0
|
|
||||||
for (let i = 0; i < this.rounds / 2; i++) {
|
for (let i = 0; i < this.rounds / 2; i++) {
|
||||||
sum = sum + TeaCipher.delta
|
sum = sum + TeaCipher.delta;
|
||||||
v0 += ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1)
|
v0 += ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1);
|
||||||
v1 += ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3)
|
v1 += ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3);
|
||||||
}
|
}
|
||||||
|
|
||||||
dst.setUint32(0, v0, false)
|
dst.setUint32(0, v0, false);
|
||||||
dst.setUint32(4, v1, false)
|
dst.setUint32(4, v1, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
decrypt(dst: DataView, src: DataView) {
|
decrypt(dst: DataView, src: DataView) {
|
||||||
let v0 = src.getUint32(0, false)
|
let v0 = src.getUint32(0, false);
|
||||||
let v1 = src.getUint32(4, false)
|
let v1 = src.getUint32(4, false);
|
||||||
|
|
||||||
let sum = TeaCipher.delta * this.rounds / 2
|
let sum = (TeaCipher.delta * this.rounds) / 2;
|
||||||
for (let i = 0; i < this.rounds / 2; i++) {
|
for (let i = 0; i < this.rounds / 2; i++) {
|
||||||
v1 -= ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3)
|
v1 -= ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3);
|
||||||
v0 -= ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1)
|
v0 -= ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1);
|
||||||
sum -= TeaCipher.delta
|
sum -= TeaCipher.delta;
|
||||||
}
|
}
|
||||||
dst.setUint32(0, v0, false)
|
dst.setUint32(0, v0, false);
|
||||||
dst.setUint32(4, v1, false)
|
dst.setUint32(4, v1, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import {FileSystemDirectoryHandle} from "@/shims-fs";
|
import { FileSystemDirectoryHandle } from '@/shims-fs';
|
||||||
|
|
||||||
export enum FilenamePolicy {
|
export enum FilenamePolicy {
|
||||||
ArtistAndTitle,
|
ArtistAndTitle,
|
||||||
@ -8,12 +8,12 @@ export enum FilenamePolicy {
|
|||||||
SameAsOriginal,
|
SameAsOriginal,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilenamePolicies: { key: FilenamePolicy, text: string }[] = [
|
export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [
|
||||||
{key: FilenamePolicy.ArtistAndTitle, text: "歌手-歌曲名"},
|
{ key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' },
|
||||||
{key: FilenamePolicy.TitleOnly, text: "歌曲名"},
|
{ key: FilenamePolicy.TitleOnly, text: '歌曲名' },
|
||||||
{key: FilenamePolicy.TitleAndArtist, text: "歌曲名-歌手"},
|
{ key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' },
|
||||||
{key: FilenamePolicy.SameAsOriginal, text: "同源文件名"},
|
{ key: FilenamePolicy.SameAsOriginal, text: '同源文件名' },
|
||||||
]
|
];
|
||||||
|
|
||||||
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
|
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
|
||||||
switch (policy) {
|
switch (policy) {
|
||||||
@ -30,24 +30,22 @@ export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy)
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
|
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
|
||||||
let filename = GetDownloadFilename(data, policy)
|
let filename = GetDownloadFilename(data, policy);
|
||||||
// prevent filename exist
|
// prevent filename exist
|
||||||
try {
|
try {
|
||||||
await dir.getFileHandle(filename)
|
await dir.getFileHandle(filename);
|
||||||
filename = `${new Date().getTime()} - ${filename}`
|
filename = `${new Date().getTime()} - ${filename}`;
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
}
|
const file = await dir.getFileHandle(filename, { create: true });
|
||||||
const file = await dir.getFileHandle(filename, {create: true})
|
const w = await file.createWritable();
|
||||||
const w = await file.createWritable()
|
await w.write(data.blob);
|
||||||
await w.write(data.blob)
|
await w.close();
|
||||||
await w.close()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
|
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = data.file;
|
a.href = data.file;
|
||||||
a.download = GetDownloadFilename(data, policy)
|
a.download = GetDownloadFilename(data, policy);
|
||||||
document.body.append(a);
|
document.body.append(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
@ -55,7 +53,7 @@ export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
|
|||||||
|
|
||||||
export function RemoveBlobMusic(data: DecryptResult) {
|
export function RemoveBlobMusic(data: DecryptResult) {
|
||||||
URL.revokeObjectURL(data.file);
|
URL.revokeObjectURL(data.file);
|
||||||
if (data.picture?.startsWith("blob:")) {
|
if (data.picture?.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(data.picture);
|
URL.revokeObjectURL(data.picture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -64,16 +62,19 @@ export class DecryptQueue {
|
|||||||
private readonly pending: (() => Promise<void>)[];
|
private readonly pending: (() => Promise<void>)[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pending = []
|
this.pending = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
queue(fn: () => Promise<void>) {
|
queue(fn: () => Promise<void>) {
|
||||||
this.pending.push(fn)
|
this.pending.push(fn);
|
||||||
this.consume()
|
this.consume();
|
||||||
}
|
}
|
||||||
|
|
||||||
private consume() {
|
private consume() {
|
||||||
const fn = this.pending.shift()
|
const fn = this.pending.shift();
|
||||||
if (fn) fn().then(() => this.consume).catch(console.error)
|
if (fn)
|
||||||
|
fn()
|
||||||
|
.then(() => this.consume)
|
||||||
|
.catch(console.error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {expose} from "threads/worker";
|
import { expose } from 'threads/worker';
|
||||||
import {CommonDecrypt} from "@/decrypt/common";
|
import { CommonDecrypt } from '@/decrypt/common';
|
||||||
|
|
||||||
expose(CommonDecrypt)
|
expose(CommonDecrypt);
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<file-selector @error="showFail" @success="showSuccess"/>
|
<file-selector @error="showFail" @success="showSuccess" />
|
||||||
|
|
||||||
<div id="app-control">
|
<div id="app-control">
|
||||||
<el-row class="mb-3">
|
<el-row class="mb-3">
|
||||||
<span>歌曲命名格式:</span>
|
<span>歌曲命名格式:</span>
|
||||||
<el-radio v-for="k in FilenamePolicies" :key="k.key"
|
<el-radio v-for="k in FilenamePolicies" :key="k.key" v-model="filename_policy" :label="k.key">
|
||||||
v-model="filename_policy" :label="k.key">
|
|
||||||
{{ k.text }}
|
{{ k.text }}
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</el-row>
|
</el-row>
|
||||||
@ -16,9 +15,9 @@
|
|||||||
|
|
||||||
<el-tooltip class="item" effect="dark" placement="top-start">
|
<el-tooltip class="item" effect="dark" placement="top-start">
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<span v-if="instant_save">工作模式: {{ dir ? "写入本地文件系统" : "调用浏览器下载" }}</span>
|
<span v-if="instant_save">工作模式: {{ dir ? '写入本地文件系统' : '调用浏览器下载' }}</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
当您使用此工具进行大量文件解锁的时候,建议开启此选项。<br/>
|
当您使用此工具进行大量文件解锁的时候,建议开启此选项。<br />
|
||||||
开启后,解锁结果将不会存留于浏览器中,防止内存不足。
|
开启后,解锁结果将不会存留于浏览器中,防止内存不足。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -27,69 +26,71 @@
|
|||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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" @play="changePlaying" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import FileSelector from '@/component/FileSelector';
|
||||||
import FileSelector from "@/component/FileSelector"
|
import PreviewTable from '@/component/PreviewTable';
|
||||||
import PreviewTable from "@/component/PreviewTable"
|
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
|
||||||
import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
components: {
|
components: {
|
||||||
FileSelector,
|
FileSelector,
|
||||||
PreviewTable
|
PreviewTable,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
tableData: [],
|
tableData: [],
|
||||||
playing_url: "",
|
playing_url: '',
|
||||||
playing_auto: false,
|
playing_auto: false,
|
||||||
filename_policy: FilenamePolicy.ArtistAndTitle,
|
filename_policy: FilenamePolicy.ArtistAndTitle,
|
||||||
instant_save: false,
|
instant_save: false,
|
||||||
FilenamePolicies,
|
FilenamePolicies,
|
||||||
dir: null
|
dir: null,
|
||||||
}
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
instant_save(val) {
|
instant_save(val) {
|
||||||
if (val) this.showDirectlySave()
|
if (val) this.showDirectlySave();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async showSuccess(data) {
|
async showSuccess(data) {
|
||||||
if (this.instant_save) {
|
if (this.instant_save) {
|
||||||
await this.saveFile(data)
|
await this.saveFile(data);
|
||||||
RemoveBlobMusic(data);
|
RemoveBlobMusic(data);
|
||||||
} else {
|
} else {
|
||||||
this.tableData.push(data);
|
this.tableData.push(data);
|
||||||
this.$notify.success({
|
this.$notify.success({
|
||||||
title: '解锁成功',
|
title: '解锁成功',
|
||||||
message: '成功解锁 ' + data.title,
|
message: '成功解锁 ' + data.title,
|
||||||
duration: 3000
|
duration: 3000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
let _rp_data = [data.title, data.artist, data.album];
|
let _rp_data = [data.title, data.artist, data.album];
|
||||||
window._paq.push(["trackEvent", "Unlock", data.rawExt + "," + data.mime, JSON.stringify(_rp_data)]);
|
window._paq.push(['trackEvent', 'Unlock', data.rawExt + ',' + data.mime, JSON.stringify(_rp_data)]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
showFail(errInfo, filename) {
|
showFail(errInfo, filename) {
|
||||||
console.error(errInfo, filename)
|
console.error(errInfo, filename);
|
||||||
this.$notify.error({
|
this.$notify.error({
|
||||||
title: '出现问题',
|
title: '出现问题',
|
||||||
message: errInfo + "," + filename +
|
message:
|
||||||
|
errInfo +
|
||||||
|
',' +
|
||||||
|
filename +
|
||||||
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
|
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
|
||||||
dangerouslyUseHTMLString: true,
|
dangerouslyUseHTMLString: true,
|
||||||
duration: 6000
|
duration: 6000,
|
||||||
});
|
});
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
|
window._paq.push(['trackEvent', 'Error', String(errInfo), filename]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
changePlaying(url) {
|
changePlaying(url) {
|
||||||
@ -97,7 +98,7 @@ export default {
|
|||||||
this.playing_auto = true;
|
this.playing_auto = true;
|
||||||
},
|
},
|
||||||
handleDeleteAll() {
|
handleDeleteAll() {
|
||||||
this.tableData.forEach(value => {
|
this.tableData.forEach((value) => {
|
||||||
RemoveBlobMusic(value);
|
RemoveBlobMusic(value);
|
||||||
});
|
});
|
||||||
this.tableData = [];
|
this.tableData = [];
|
||||||
@ -106,7 +107,7 @@ export default {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
let c = setInterval(() => {
|
let c = setInterval(() => {
|
||||||
if (index < this.tableData.length) {
|
if (index < this.tableData.length) {
|
||||||
this.saveFile(this.tableData[index])
|
this.saveFile(this.tableData[index]);
|
||||||
index++;
|
index++;
|
||||||
} else {
|
} else {
|
||||||
clearInterval(c);
|
clearInterval(c);
|
||||||
@ -116,42 +117,40 @@ export default {
|
|||||||
|
|
||||||
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);
|
||||||
this.$notify({
|
this.$notify({
|
||||||
title: "保存成功",
|
title: '保存成功',
|
||||||
message: data.title,
|
message: data.title,
|
||||||
position: "top-left",
|
position: 'top-left',
|
||||||
type: "success",
|
type: 'success',
|
||||||
duration: 3000
|
duration: 3000,
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
DownloadBlobMusic(data, this.filename_policy)
|
DownloadBlobMusic(data, this.filename_policy);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async showDirectlySave() {
|
async showDirectlySave() {
|
||||||
if (!window.showDirectoryPicker) return
|
if (!window.showDirectoryPicker) return;
|
||||||
try {
|
try {
|
||||||
await this.$confirm("您的浏览器支持文件直接保存到磁盘,是否使用?",
|
await this.$confirm('您的浏览器支持文件直接保存到磁盘,是否使用?', '新特性提示', {
|
||||||
"新特性提示", {
|
confirmButtonText: '使用',
|
||||||
confirmButtonText: "使用",
|
cancelButtonText: '不使用',
|
||||||
cancelButtonText: "不使用",
|
type: 'warning',
|
||||||
type: "warning",
|
center: true,
|
||||||
center: true
|
});
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
console.log(e);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
this.dir = await window.showDirectoryPicker()
|
this.dir = await window.showDirectoryPicker();
|
||||||
const test_filename = "__unlock_music_write_test.txt"
|
const test_filename = '__unlock_music_write_test.txt';
|
||||||
await this.dir.getFileHandle(test_filename, {create: true})
|
await this.dir.getFileHandle(test_filename, { create: true });
|
||||||
await this.dir.removeEntry(test_filename)
|
await this.dir.removeEntry(test_filename);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e)
|
console.error(e);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user