Browse Source

all: format with prettier

(cherry picked from commit cad5b4d7deba4fbe4a40a17306ce49d3b2f13139)
20230320
MengYX 3 years ago
parent
commit
3441b7a3b1
No known key found for this signature in database GPG Key ID: E63F9C7303E8F604
  1. 44
      src/App.vue
  2. 85
      src/component/FileSelector.vue
  3. 35
      src/component/PreviewTable.vue
  4. 102
      src/decrypt/common.ts
  5. 35
      src/decrypt/entity.ts
  6. 129
      src/decrypt/kgm.ts
  7. 55
      src/decrypt/kwm.ts
  8. 197
      src/decrypt/ncm.ts
  9. 21
      src/decrypt/ncmcache.ts
  10. 31
      src/decrypt/qmc.test.ts
  11. 182
      src/decrypt/qmc.ts
  12. 134
      src/decrypt/qmc_cipher.test.ts
  13. 72
      src/decrypt/qmc_cipher.ts
  14. 34
      src/decrypt/qmc_key.test.ts
  15. 64
      src/decrypt/qmc_key.ts
  16. 22
      src/decrypt/qmc_wasm.ts
  17. 37
      src/decrypt/qmccache.ts
  18. 22
      src/decrypt/raw.ts
  19. 10
      src/decrypt/tm.ts
  20. 169
      src/decrypt/utils.ts
  21. 65
      src/decrypt/xm.ts
  22. 7
      src/extension/popup.js
  23. 10
      src/main.ts
  24. 21
      src/registerServiceWorker.js
  25. 22
      src/shims-browser-id3-writer.d.ts
  26. 42
      src/shims-fs.d.ts
  27. 10
      src/shims-tsx.d.ts
  28. 4
      src/shims-vue.d.ts
  29. 75
      src/utils/api.ts
  30. 65
      src/utils/tea.test.ts
  31. 60
      src/utils/tea.ts
  32. 51
      src/utils/utils.ts
  33. 6
      src/utils/worker.ts
  34. 101
      src/view/Home.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 &copy; 2019 - {{ (new Date()).getFullYear() }} MengYX</span> <span>Copyright &copy; 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>

85
src/component/FileSelector.vue

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

35
src/component/PreviewTable.vue

@ -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 circle
icon="el-icon-download" @click="handleDownload(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-delete" type="danger" @click="handleDelete(scope.$index, scope.row)"> <el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
</el-button> </el-button>
</template> </template>
</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>

102
src/decrypt/common.ts

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

35
src/decrypt/entity.ts

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

129
src/decrypt/kgm.ts

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

55
src/decrypt/kwm.ts

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

197
src/decrypt/ncm.ts

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

21
src/decrypt/ncmcache.ts

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

31
src/decrypt/qmc.test.ts

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

182
src/decrypt/qmc.ts

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

134
src/decrypt/qmc_cipher.test.ts

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

72
src/decrypt/qmc_cipher.ts

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

34
src/decrypt/qmc_key.test.ts

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

64
src/decrypt/qmc_key.ts

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

22
src/decrypt/qmc_wasm.ts

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

37
src/decrypt/qmccache.ts

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

22
src/decrypt/raw.ts

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

10
src/decrypt/tm.ts

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

169
src/decrypt/utils.ts

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

65
src/decrypt/xm.ts

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

7
src/extension/popup.js

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

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

21
src/registerServiceWorker.js

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

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

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

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

@ -1,4 +1,4 @@
declare module '*.vue' { declare module '*.vue' {
import Vue from 'vue' import Vue from 'vue';
export default Vue export default Vue;
} }

75
src/utils/api.ts

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

65
src/utils/tea.test.ts

@ -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))
.toThrow()
expect(() => new TeaCipher(testKey.slice(0, 15)))
.toThrow()
}) expect(() => new TeaCipher(testKey)).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);
} }
}) });

60
src/utils/tea.ts

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

51
src/utils/utils.ts

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

6
src/utils/worker.ts

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

101
src/view/Home.vue

@ -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…
Cancel
Save