Browse Source
feat: add basic joox support
(cherry picked from commit 699333ca06526d747a7eb4a188e896de81e9f014)
20230320
Jixun
3 years ago
committed by
MengYX
No known key found for this signature in database
GPG Key ID: E63F9C7303E8F604
13 changed files with
207 additions and
16 deletions
-
package-lock.json
-
package.json
-
src/component/ConfigDialog.vue
-
src/decrypt/common.ts
-
src/decrypt/joox.ts
-
src/decrypt/qmc_wasm.ts
-
src/main.ts
-
src/utils/MergeUint8Array.ts
-
src/utils/storage.ts
-
src/utils/storage/BaseStorage.ts
-
src/utils/storage/BrowserNativeStorage.ts
-
src/utils/storage/ChromeExtensionStorage.ts
-
src/view/Home.vue
|
|
@ -12,6 +12,7 @@ |
|
|
|
"dependencies": { |
|
|
|
"@babel/preset-typescript": "^7.16.5", |
|
|
|
"@jixun/qmc2-crypto": "^0.0.5-R4", |
|
|
|
"@unlock-music-gh/joox-crypto": "^0.0.1-R2", |
|
|
|
"base64-js": "^1.5.1", |
|
|
|
"browser-id3-writer": "^4.4.0", |
|
|
|
"core-js": "^3.16.0", |
|
|
@ -3485,6 +3486,17 @@ |
|
|
|
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", |
|
|
|
"dev": true |
|
|
|
}, |
|
|
|
"node_modules/@unlock-music-gh/joox-crypto": { |
|
|
|
"version": "0.0.1-R3", |
|
|
|
"resolved": "https://registry.npmjs.org/@unlock-music-gh/joox-crypto/-/joox-crypto-0.0.1-R3.tgz", |
|
|
|
"integrity": "sha512-zZRiDXKI5SxuBIcW/rsGL8jNvyWxtA5cNRfg69WcsZK2DqztY8M2q1kMe96MP1AyM+cKpNQ50jAKh77VdFv9rA==", |
|
|
|
"dependencies": { |
|
|
|
"crypto-js": "^4.1.1" |
|
|
|
}, |
|
|
|
"bin": { |
|
|
|
"joox-decrypt": "joox-decrypt" |
|
|
|
} |
|
|
|
}, |
|
|
|
"node_modules/@vue/babel-helper-vue-jsx-merge-props": { |
|
|
|
"version": "1.2.1", |
|
|
|
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz", |
|
|
@ -23622,6 +23634,14 @@ |
|
|
|
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", |
|
|
|
"dev": true |
|
|
|
}, |
|
|
|
"@unlock-music-gh/joox-crypto": { |
|
|
|
"version": "0.0.1-R3", |
|
|
|
"resolved": "https://registry.npmjs.org/@unlock-music-gh/joox-crypto/-/joox-crypto-0.0.1-R3.tgz", |
|
|
|
"integrity": "sha512-zZRiDXKI5SxuBIcW/rsGL8jNvyWxtA5cNRfg69WcsZK2DqztY8M2q1kMe96MP1AyM+cKpNQ50jAKh77VdFv9rA==", |
|
|
|
"requires": { |
|
|
|
"crypto-js": "^4.1.1" |
|
|
|
} |
|
|
|
}, |
|
|
|
"@vue/babel-helper-vue-jsx-merge-props": { |
|
|
|
"version": "1.2.1", |
|
|
|
"resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz", |
|
|
|
|
|
@ -22,6 +22,7 @@ |
|
|
|
"dependencies": { |
|
|
|
"@babel/preset-typescript": "^7.16.5", |
|
|
|
"@jixun/qmc2-crypto": "^0.0.5-R4", |
|
|
|
"@unlock-music-gh/joox-crypto": "^0.0.1-R2", |
|
|
|
"base64-js": "^1.5.1", |
|
|
|
"browser-id3-writer": "^4.4.0", |
|
|
|
"core-js": "^3.16.0", |
|
|
|
|
|
@ -0,0 +1,53 @@ |
|
|
|
<template> |
|
|
|
<el-dialog fullscreen @close="cancel()" title="解密设定" :visible="show" width="30%" center> |
|
|
|
<el-form ref="form" :model="form" label-width="80px"> |
|
|
|
<el-form-item label="Joox UUID"> |
|
|
|
<el-input type="text" placeholder="UUID" v-model="form.jooxUUID" clearable maxlength="32" show-word-limit> |
|
|
|
</el-input> |
|
|
|
</el-form-item> |
|
|
|
</el-form> |
|
|
|
<span slot="footer" class="dialog-footer"> |
|
|
|
<el-button type="primary" :loading="saving" @click="emitConfirm()">确 定</el-button> |
|
|
|
</span> |
|
|
|
</el-dialog> |
|
|
|
</template> |
|
|
|
|
|
|
|
<script> |
|
|
|
import storage from '../utils/storage'; |
|
|
|
|
|
|
|
export default { |
|
|
|
components: {}, |
|
|
|
props: { |
|
|
|
show: { type: Boolean, required: true }, |
|
|
|
}, |
|
|
|
data() { |
|
|
|
return { |
|
|
|
saving: false, |
|
|
|
form: { |
|
|
|
jooxUUID: '', |
|
|
|
}, |
|
|
|
centerDialogVisible: false, |
|
|
|
}; |
|
|
|
}, |
|
|
|
async mounted() { |
|
|
|
await this.resetForm(); |
|
|
|
}, |
|
|
|
methods: { |
|
|
|
async resetForm() { |
|
|
|
this.form.jooxUUID = await storage.loadJooxUUID(); |
|
|
|
}, |
|
|
|
|
|
|
|
async cancel() { |
|
|
|
await this.resetForm(); |
|
|
|
this.$emit('done'); |
|
|
|
}, |
|
|
|
|
|
|
|
async emitConfirm() { |
|
|
|
this.saving = true; |
|
|
|
await storage.saveJooxUUID(this.form.jooxUUID); |
|
|
|
this.saving = false; |
|
|
|
this.$emit('done'); |
|
|
|
}, |
|
|
|
}, |
|
|
|
}; |
|
|
|
</script> |
|
|
@ -7,6 +7,7 @@ import { Decrypt as KgmDecrypt } from '@/decrypt/kgm'; |
|
|
|
import { Decrypt as KwmDecrypt } from '@/decrypt/kwm'; |
|
|
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw'; |
|
|
|
import { Decrypt as TmDecrypt } from '@/decrypt/tm'; |
|
|
|
import { Decrypt as JooxDecrypt } from '@/decrypt/joox'; |
|
|
|
import { DecryptResult, FileInfo } from '@/decrypt/entity'; |
|
|
|
import { SplitFilename } from '@/decrypt/utils'; |
|
|
|
|
|
|
@ -68,6 +69,9 @@ export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> { |
|
|
|
case 'kgma': |
|
|
|
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext); |
|
|
|
break; |
|
|
|
case 'ofl_en': |
|
|
|
rt_data = await JooxDecrypt(file.raw, raw.name, raw.ext); |
|
|
|
break; |
|
|
|
default: |
|
|
|
throw '不支持此文件格式'; |
|
|
|
} |
|
|
|
|
|
@ -0,0 +1,34 @@ |
|
|
|
import { DecryptResult } from './entity'; |
|
|
|
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from './utils'; |
|
|
|
|
|
|
|
import jooxFactory from '@unlock-music-gh/joox-crypto'; |
|
|
|
import storage from '@/utils/storage'; |
|
|
|
import { MergeUint8Array } from '@/utils/MergeUint8Array'; |
|
|
|
|
|
|
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> { |
|
|
|
const uuid = await storage.loadJooxUUID(''); |
|
|
|
if (!uuid || uuid.length !== 32) { |
|
|
|
throw new Error('请在“解密设定”填写应用 Joox 应用的 UUID。'); |
|
|
|
} |
|
|
|
|
|
|
|
const fileBuffer = new Uint8Array(await GetArrayBuffer(file)); |
|
|
|
const decryptor = jooxFactory(fileBuffer, uuid); |
|
|
|
if (!decryptor) { |
|
|
|
throw new Error('不支持的 joox 加密格式'); |
|
|
|
} |
|
|
|
|
|
|
|
const musicDecoded = MergeUint8Array(decryptor.decryptFile(fileBuffer)); |
|
|
|
const ext = SniffAudioExt(musicDecoded); |
|
|
|
const mime = AudioMimeType[ext]; |
|
|
|
const musicBlob = new Blob([musicDecoded], { type: mime }); |
|
|
|
|
|
|
|
return { |
|
|
|
title: raw_filename.replace(/\.[^\.]+$/, ''), |
|
|
|
artist: '未知', |
|
|
|
album: '未知', |
|
|
|
file: URL.createObjectURL(musicBlob), |
|
|
|
blob: musicBlob, |
|
|
|
mime: mime, |
|
|
|
ext: ext, |
|
|
|
}; |
|
|
|
} |
|
|
@ -1,4 +1,5 @@ |
|
|
|
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle'; |
|
|
|
import { MergeUint8Array } from '@/utils/MergeUint8Array'; |
|
|
|
|
|
|
|
// 检测文件末端使用的缓冲区大小
|
|
|
|
const DETECTION_SIZE = 40; |
|
|
@ -6,22 +7,6 @@ const DETECTION_SIZE = 40; |
|
|
|
// 每次处理 2M 的数据
|
|
|
|
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024; |
|
|
|
|
|
|
|
function MergeUint8Array(array: Uint8Array[]): Uint8Array { |
|
|
|
let length = 0; |
|
|
|
array.forEach((item) => { |
|
|
|
length += item.length; |
|
|
|
}); |
|
|
|
|
|
|
|
let mergedArray = new Uint8Array(length); |
|
|
|
let offset = 0; |
|
|
|
array.forEach((item) => { |
|
|
|
mergedArray.set(item, offset); |
|
|
|
offset += item.length; |
|
|
|
}); |
|
|
|
|
|
|
|
return mergedArray; |
|
|
|
} |
|
|
|
|
|
|
|
/** |
|
|
|
* 解密一个 QMC2 加密的文件。 |
|
|
|
* |
|
|
|
|
|
@ -6,9 +6,13 @@ import { |
|
|
|
Checkbox, |
|
|
|
Col, |
|
|
|
Container, |
|
|
|
Dialog, |
|
|
|
Form, |
|
|
|
FormItem, |
|
|
|
Footer, |
|
|
|
Icon, |
|
|
|
Image, |
|
|
|
Input, |
|
|
|
Link, |
|
|
|
Main, |
|
|
|
Notification, |
|
|
@ -26,6 +30,10 @@ import 'element-ui/lib/theme-chalk/base.css'; |
|
|
|
Vue.use(Link); |
|
|
|
Vue.use(Image); |
|
|
|
Vue.use(Button); |
|
|
|
Vue.use(Dialog); |
|
|
|
Vue.use(Form); |
|
|
|
Vue.use(FormItem); |
|
|
|
Vue.use(Input); |
|
|
|
Vue.use(Table); |
|
|
|
Vue.use(TableColumn); |
|
|
|
Vue.use(Main); |
|
|
|
|
|
@ -0,0 +1,15 @@ |
|
|
|
export function MergeUint8Array(array: Uint8Array[]): Uint8Array { |
|
|
|
let length = 0; |
|
|
|
array.forEach((item) => { |
|
|
|
length += item.length; |
|
|
|
}); |
|
|
|
|
|
|
|
let mergedArray = new Uint8Array(length); |
|
|
|
let offset = 0; |
|
|
|
array.forEach((item) => { |
|
|
|
mergedArray.set(item, offset); |
|
|
|
offset += item.length; |
|
|
|
}); |
|
|
|
|
|
|
|
return mergedArray; |
|
|
|
} |
|
|
@ -0,0 +1,7 @@ |
|
|
|
import BaseStorage from './storage/BaseStorage'; |
|
|
|
import BrowserNativeStorage from './storage/BrowserNativeStorage'; |
|
|
|
import ChromeExtensionStorage from './storage/ChromeExtensionStorage'; |
|
|
|
|
|
|
|
const storage: BaseStorage = ChromeExtensionStorage.works ? new ChromeExtensionStorage() : new BrowserNativeStorage(); |
|
|
|
|
|
|
|
export default storage; |
|
|
@ -0,0 +1,14 @@ |
|
|
|
const KEY_JOOX_UUID = 'joox.uuid'; |
|
|
|
|
|
|
|
export default abstract class BaseStorage { |
|
|
|
protected abstract save<T>(name: string, value: T): Promise<void>; |
|
|
|
protected abstract load<T>(name: string, defaultValue: T): Promise<T>; |
|
|
|
|
|
|
|
public saveJooxUUID(uuid: string): Promise<void> { |
|
|
|
return this.save(KEY_JOOX_UUID, uuid); |
|
|
|
} |
|
|
|
|
|
|
|
public loadJooxUUID(defaultValue: string = ''): Promise<string> { |
|
|
|
return this.load(KEY_JOOX_UUID, defaultValue); |
|
|
|
} |
|
|
|
} |
|
|
@ -0,0 +1,15 @@ |
|
|
|
import BaseStorage from './BaseStorage'; |
|
|
|
|
|
|
|
export default class BrowserNativeStorage extends BaseStorage { |
|
|
|
protected async load<T>(name: string, defaultValue: T): Promise<T> { |
|
|
|
const result = localStorage.getItem(name); |
|
|
|
if (result === null) { |
|
|
|
return defaultValue; |
|
|
|
} |
|
|
|
return JSON.parse(result); |
|
|
|
} |
|
|
|
|
|
|
|
protected async save<T>(name: string, value: T): Promise<void> { |
|
|
|
localStorage.setItem(name, JSON.stringify(value)); |
|
|
|
} |
|
|
|
} |
|
|
@ -0,0 +1,21 @@ |
|
|
|
import BaseStorage from './BaseStorage'; |
|
|
|
|
|
|
|
declare var chrome: any; |
|
|
|
|
|
|
|
export default class ChromeExtensionStorage extends BaseStorage { |
|
|
|
static get works(): boolean { |
|
|
|
return Boolean(chrome?.storage?.local?.set); |
|
|
|
} |
|
|
|
|
|
|
|
protected async load<T>(name: string, defaultValue: T): Promise<T> { |
|
|
|
const result = await chrome.storage.local.get({ [name]: defaultValue }); |
|
|
|
if (Object.prototype.hasOwnProperty.call(result, name)) { |
|
|
|
return result[name]; |
|
|
|
} |
|
|
|
return defaultValue; |
|
|
|
} |
|
|
|
|
|
|
|
protected async save<T>(name: string, value: T): Promise<void> { |
|
|
|
return chrome.storage.local.set({ [name]: value }); |
|
|
|
} |
|
|
|
} |
|
|
@ -10,6 +10,13 @@ |
|
|
|
</el-radio> |
|
|
|
</el-row> |
|
|
|
<el-row> |
|
|
|
<config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog> |
|
|
|
<el-tooltip class="item" effect="dark" placement="top"> |
|
|
|
<div slot="content"> |
|
|
|
<span> 部分解密方案需要设定解密参数。 </span> |
|
|
|
</div> |
|
|
|
<el-button icon="el-icon-s-tools" plain @click="showConfigDialog = true">解密设定</el-button> |
|
|
|
</el-tooltip> |
|
|
|
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button> |
|
|
|
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button> |
|
|
|
|
|
|
@ -35,6 +42,8 @@ |
|
|
|
<script> |
|
|
|
import FileSelector from '@/component/FileSelector'; |
|
|
|
import PreviewTable from '@/component/PreviewTable'; |
|
|
|
import ConfigDialog from '@/component/ConfigDialog'; |
|
|
|
|
|
|
|
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils'; |
|
|
|
|
|
|
|
export default { |
|
|
@ -42,9 +51,11 @@ export default { |
|
|
|
components: { |
|
|
|
FileSelector, |
|
|
|
PreviewTable, |
|
|
|
ConfigDialog, |
|
|
|
}, |
|
|
|
data() { |
|
|
|
return { |
|
|
|
showConfigDialog: false, |
|
|
|
tableData: [], |
|
|
|
playing_url: '', |
|
|
|
playing_auto: false, |
|
|
@ -103,6 +114,9 @@ export default { |
|
|
|
}); |
|
|
|
this.tableData = []; |
|
|
|
}, |
|
|
|
handleDecryptionConfig() { |
|
|
|
this.showConfigDialog = true; |
|
|
|
}, |
|
|
|
handleDownloadAll() { |
|
|
|
let index = 0; |
|
|
|
let c = setInterval(() => { |
|
|
|