forked from um/web
Merge pull request #215 from jixunmoe/feature/joox-encryption
提供 joox 解密/meta 更新支持
This commit is contained in:
commit
4dc197d3af
11
.github/workflows/build.yml
vendored
11
.github/workflows/build.yml
vendored
@ -1,3 +1,5 @@
|
|||||||
|
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||||
|
|
||||||
name: Test Build
|
name: Test Build
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@ -27,7 +29,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6
|
# note: forks can not access to GITHUB_TOKEN for coverage update.
|
||||||
|
# instead, we just ran the test in this case.
|
||||||
|
- name: Test only
|
||||||
|
if: github.event_name != 'push'
|
||||||
|
run: npm test
|
||||||
|
- name: Test + Publish Coverage
|
||||||
|
uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6
|
||||||
|
if: github.event_name == 'push'
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
annotations: none
|
annotations: none
|
||||||
|
70
README.md
70
README.md
@ -1,40 +1,48 @@
|
|||||||
# Unlock Music 音乐解锁
|
# Unlock Music 音乐解锁
|
||||||
|
|
||||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||||
- unlock-music项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE)
|
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循 [License][license]
|
||||||
- Unlock Music的CLI版本正在开发中。
|
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli][repo_cli] 找到,大批量转换建议使用 CLI 版本。
|
||||||
- 我们新建了Telegram群组,欢迎加入![https://t.me/unlock_music_chat](https://t.me/unlock_music_chat)
|
- 我们新建了 Telegram 群组 [`@unlock_music_chat`][tg_group] ,欢迎加入!
|
||||||
- [CLI版本 Alpha](https://github.com/unlock-music/cli) 大批量转换建议使用CLI版本
|
- [相关的其他项目][related_projects]
|
||||||
- [相关的其他项目](https://github.com/ix64/unlock-music/wiki/%E5%92%8CUnlockMusic%E7%9B%B8%E5%85%B3%E7%9A%84%E9%A1%B9%E7%9B%AE)
|
|
||||||
|
|
||||||
![Test Build](https://github.com/ix64/unlock-music/workflows/Test%20Build/badge.svg)
|
![Test Build](https://github.com/unlock-music/unlock-music/workflows/Test%20Build/badge.svg)
|
||||||
![GitHub releases](https://img.shields.io/github/downloads/ix64/unlock-music/total)
|
![GitHub releases](https://img.shields.io/github/downloads/unlock-music/unlock-music/total)
|
||||||
![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music)
|
![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music)
|
||||||
|
|
||||||
|
[license]: https://github.com/unlock-music/unlock-music/blob/master/LICENSE
|
||||||
|
|
||||||
|
[repo_cli]: https://github.com/unlock-music/cli
|
||||||
|
|
||||||
|
[tg_group]: https://t.me/unlock_music_chat
|
||||||
|
|
||||||
|
[related_projects]: https://github.com/unlock-music/unlock-music/wiki/和UnlockMusic相关的项目
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
### 支持的格式
|
### 支持的格式
|
||||||
|
|
||||||
- [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/ix64/unlock-music/issues/9))
|
- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
|
||||||
- [x] 写入封面图片
|
- [x] Moo 音乐格式 (.bkcmp3/.bkcflac)
|
||||||
- [x] Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
|
|
||||||
- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6)
|
- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6)
|
||||||
- [x] QQ音乐新格式 (实验性支持)
|
- [x] QQ 音乐新格式 ([.mflac/.mgg](https://github.com/unlock-music/unlock-music/issues/3))
|
||||||
- [x] .mflac
|
- [x] <ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.)
|
||||||
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
|
|
||||||
- [x] 虾米音乐格式 (.xm) (测试阶段)
|
- [x] 虾米音乐格式 (.xm) (测试阶段)
|
||||||
- [x] 酷我音乐格式 (.kwm) (测试阶段)
|
- [x] 酷我音乐格式 (.kwm) (测试阶段)
|
||||||
- [x] 酷狗音乐格式 (
|
- [x] 酷狗音乐格式 (.kgm) ([CLI 版本][kgm_cli])
|
||||||
.kgm) ([CLI版本](https://github.com/ix64/unlock-music/wiki/%E5%85%B6%E4%BB%96%E9%9F%B3%E4%B9%90%E6%A0%BC%E5%BC%8F%E5%B7%A5%E5%85%B7#%E9%85%B7%E7%8B%97%E9%9F%B3%E4%B9%90-kgmvpr%E8%A7%A3%E9%94%81%E5%B7%A5%E5%85%B7))
|
|
||||||
|
[kgm_cli]: https://github.com/unlock-music/unlock-music/wiki/其他音乐格式工具#酷狗音乐-kgmvpr解锁工具
|
||||||
|
|
||||||
|
[joox_wiki]: https://github.com/unlock-music/joox-crypto/wiki/加密格式
|
||||||
|
|
||||||
### 其他特性
|
### 其他特性
|
||||||
|
|
||||||
- [x] 在浏览器中解锁
|
- [x] 在浏览器中解锁
|
||||||
- [x] 拖放文件
|
- [x] 拖放文件
|
||||||
- [x] 在线播放
|
|
||||||
- [x] 批量解锁
|
- [x] 批量解锁
|
||||||
- [x] 渐进式Web应用
|
- [x] 渐进式 Web 应用 (PWA)
|
||||||
- [x] 多线程
|
- [x] 多线程
|
||||||
|
- [x] 写入Meta和封面图片
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
@ -46,8 +54,8 @@
|
|||||||
|
|
||||||
### 使用已构建版本
|
### 使用已构建版本
|
||||||
|
|
||||||
- 从[GitHub Release](https://github.com/ix64/unlock-music/releases/latest)下载已构建的版本
|
- 从[GitHub Release](https://github.com/unlock-music/unlock-music/releases/latest)下载已构建的版本
|
||||||
- 本地使用请下载`legacy版本`(`modern版本`只能通过**http/https协议**访问)
|
- 本地使用请下载`legacy版本`(`modern版本`只能通过 **http(s)协议** 访问)
|
||||||
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**)
|
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**)
|
||||||
|
|
||||||
### 使用 Docker 镜像
|
### 使用 Docker 镜像
|
||||||
@ -59,11 +67,25 @@ docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
|
|||||||
### 自行构建
|
### 自行构建
|
||||||
|
|
||||||
- 环境要求
|
- 环境要求
|
||||||
- nodejs
|
- nodejs (v16.x)
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
1. 获取项目源代码后执行 `npm install` 安装相关依赖
|
1. 获取项目源代码后安装相关依赖:
|
||||||
2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录
|
|
||||||
|
|
||||||
- `npm run serve` 可用于开发
|
```sh
|
||||||
3. 如需构建浏览器扩展,build完成后还需要执行`npm run make-extension`
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 然后进行构建。编译后的文件保存到 dist 目录下:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- 如果是用于开发,可以执行 `npm run serve`。
|
||||||
|
|
||||||
|
3. 如需构建浏览器扩展,build 完成后还需要执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run make-extension
|
||||||
|
```
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
"128": "./img/icons/msapplication-icon-144x144.png"
|
"128": "./img/icons/msapplication-icon-144x144.png"
|
||||||
},
|
},
|
||||||
"description": "在任何设备上解锁已购的加密音乐!",
|
"description": "在任何设备上解锁已购的加密音乐!",
|
||||||
|
"permissions": ["storage"],
|
||||||
"offline_enabled": true,
|
"offline_enabled": true,
|
||||||
"options_page": "./index.html",
|
"options_page": "./index.html",
|
||||||
"homepage_url": "https://github.com/ix64/unlock-music",
|
"homepage_url": "https://github.com/ix64/unlock-music",
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
|
setupFilesAfterEnv: [
|
||||||
|
'./src/__test__/setup_jest.js'
|
||||||
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'@/(.*)': '<rootDir>/src/$1'
|
'@/(.*)': '<rootDir>/src/$1'
|
||||||
}
|
}
|
||||||
|
20
package-lock.json
generated
20
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/preset-typescript": "^7.16.5",
|
"@babel/preset-typescript": "^7.16.5",
|
||||||
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
||||||
|
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"browser-id3-writer": "^4.4.0",
|
"browser-id3-writer": "^4.4.0",
|
||||||
"core-js": "^3.16.0",
|
"core-js": "^3.16.0",
|
||||||
@ -3485,6 +3486,17 @@
|
|||||||
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==",
|
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@unlock-music/joox-crypto": {
|
||||||
|
"version": "0.0.1-R5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unlock-music/joox-crypto/-/joox-crypto-0.0.1-R5.tgz",
|
||||||
|
"integrity": "sha512-+FhGT4bjzfb1Q7dAwHps/XqbqXrRA6Qg7pkDPzyXfeRmQocAySQ/dekojxkaFBf7ZX5ToIAopwxkKZ5NFt5bFw==",
|
||||||
|
"dependencies": {
|
||||||
|
"crypto-js": "^4.1.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"joox-decrypt": "joox-decrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/babel-helper-vue-jsx-merge-props": {
|
"node_modules/@vue/babel-helper-vue-jsx-merge-props": {
|
||||||
"version": "1.2.1",
|
"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",
|
"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==",
|
"integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"@unlock-music/joox-crypto": {
|
||||||
|
"version": "0.0.1-R5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@unlock-music/joox-crypto/-/joox-crypto-0.0.1-R5.tgz",
|
||||||
|
"integrity": "sha512-+FhGT4bjzfb1Q7dAwHps/XqbqXrRA6Qg7pkDPzyXfeRmQocAySQ/dekojxkaFBf7ZX5ToIAopwxkKZ5NFt5bFw==",
|
||||||
|
"requires": {
|
||||||
|
"crypto-js": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@vue/babel-helper-vue-jsx-merge-props": {
|
"@vue/babel-helper-vue-jsx-merge-props": {
|
||||||
"version": "1.2.1",
|
"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",
|
"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": {
|
"dependencies": {
|
||||||
"@babel/preset-typescript": "^7.16.5",
|
"@babel/preset-typescript": "^7.16.5",
|
||||||
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
||||||
|
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"browser-id3-writer": "^4.4.0",
|
"browser-id3-writer": "^4.4.0",
|
||||||
"core-js": "^3.16.0",
|
"core-js": "^3.16.0",
|
||||||
|
2
src/__test__/setup_jest.js
Normal file
2
src/__test__/setup_jest.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Polyfill for node.
|
||||||
|
global.Blob = global.Blob || require("node:buffer").Blob;
|
113
src/component/ConfigDialog.vue
Normal file
113
src/component/ConfigDialog.vue
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
<style scoped>
|
||||||
|
label {
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.2;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.item-desc {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: small;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.2;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
.item-desc a {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
form >>> input {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* >>> .um-config-dialog {
|
||||||
|
max-width: 90%;
|
||||||
|
width: 40em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog @close="cancel()" title="解密设定" :visible="show" custom-class="um-config-dialog" center>
|
||||||
|
<el-form ref="form" :rules="rules" status-icon :model="form" label-width="0">
|
||||||
|
<section>
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
JOOX Music ·
|
||||||
|
<Ruby caption="Unique Device Identifier">设备唯一识别码</Ruby>
|
||||||
|
</span>
|
||||||
|
<el-form-item prop="jooxUUID">
|
||||||
|
<el-input type="text" v-model="form.jooxUUID" clearable maxlength="32" show-word-limit> </el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="item-desc">
|
||||||
|
下载该加密文件的 JOOX 应用所记录的设备唯一识别码。
|
||||||
|
<br />
|
||||||
|
参见:
|
||||||
|
<a href="https://github.com/unlock-music/joox-crypto/wiki/%E8%8E%B7%E5%8F%96%E8%AE%BE%E5%A4%87-UUID">
|
||||||
|
获取设备 UUID · unlock-music/joox-crypto Wiki</a
|
||||||
|
>。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</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';
|
||||||
|
import Ruby from './Ruby';
|
||||||
|
|
||||||
|
// FIXME: 看起来不会触发这个验证提示?
|
||||||
|
function validateJooxUUID(rule, value, callback) {
|
||||||
|
if (!value || !/^[\da-fA-F]{32}$/.test(value)) {
|
||||||
|
callback(new Error('无效的 Joox UUID,请参考 Wiki 获取。'));
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
jooxUUID: { validator: validateJooxUUID, trigger: 'change' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Ruby,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
show: { type: Boolean, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
rules,
|
||||||
|
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>
|
@ -39,6 +39,7 @@
|
|||||||
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';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FileSelector',
|
name: 'FileSelector',
|
||||||
@ -76,7 +77,7 @@ export default {
|
|||||||
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, await storage.getAll()));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
this.$emit('error', e, file.name);
|
this.$emit('error', e, file.name);
|
||||||
|
18
src/component/Ruby.vue
Normal file
18
src/component/Ruby.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<ruby :title="caption">
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
|
<rp>(</rp>
|
||||||
|
<rt v-text="caption"></rt>
|
||||||
|
<rp>)</rp>
|
||||||
|
</ruby>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'Ruby',
|
||||||
|
props: {
|
||||||
|
caption: { type: String, required: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
BIN
src/decrypt/__test__/fixture/joox_1.bin
Normal file
BIN
src/decrypt/__test__/fixture/joox_1.bin
Normal file
Binary file not shown.
52
src/decrypt/__test__/joox.test.ts
Normal file
52
src/decrypt/__test__/joox.test.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
|
||||||
|
import { Decrypt as decryptJoox } from '../joox';
|
||||||
|
import { extractQQMusicMeta as extractQQMusicMetaOrig } from '@/utils/qm_meta';
|
||||||
|
|
||||||
|
jest.mock('@/utils/storage');
|
||||||
|
jest.mock('@/utils/qm_meta');
|
||||||
|
|
||||||
|
const loadJooxUUID = storage.loadJooxUUID as jest.MockedFunction<typeof storage.loadJooxUUID>;
|
||||||
|
const extractQQMusicMeta = extractQQMusicMetaOrig as jest.MockedFunction<typeof extractQQMusicMetaOrig>;
|
||||||
|
|
||||||
|
const TEST_UUID_ZEROS = ''.padStart(32, '0');
|
||||||
|
const encryptedFile1 = fs.readFileSync(__dirname + '/fixture/joox_1.bin');
|
||||||
|
|
||||||
|
describe('decrypt/joox', () => {
|
||||||
|
it('should be able to decrypt sample file (v4)', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS);
|
||||||
|
extractQQMusicMeta.mockImplementationOnce(async (blob: Blob) => {
|
||||||
|
return {
|
||||||
|
title: 'unused',
|
||||||
|
album: 'unused',
|
||||||
|
blob: blob,
|
||||||
|
artist: 'unused',
|
||||||
|
imgUrl: 'https://github.com/unlock-music',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await decryptJoox(new Blob([encryptedFile1]), 'test.bin', 'bin');
|
||||||
|
const resultBuf = await result.blob.arrayBuffer();
|
||||||
|
expect(resultBuf).toEqual(Buffer.from('Hello World', 'utf-8').buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject E!99 files', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS);
|
||||||
|
|
||||||
|
const input = new Blob([Buffer.from('E!99....')]);
|
||||||
|
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('不支持的 joox 加密格式');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty uuid', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue('');
|
||||||
|
const input = new Blob([encryptedFile1]);
|
||||||
|
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid uuid', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue('hello!');
|
||||||
|
const input = new Blob([encryptedFile1]);
|
||||||
|
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID');
|
||||||
|
});
|
||||||
|
});
|
@ -5,10 +5,18 @@ 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 { Decrypt as JooxDecrypt } from '@/decrypt/joox';
|
||||||
import { DecryptResult, FileInfo } from '@/decrypt/entity';
|
import { DecryptResult, FileInfo } from '@/decrypt/entity';
|
||||||
import { SplitFilename } from '@/decrypt/utils';
|
import { SplitFilename } from '@/decrypt/utils';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
import InMemoryStorage from '@/utils/storage/InMemoryStorage';
|
||||||
|
|
||||||
|
export async function CommonDecrypt(file: FileInfo, config: Record<string, any>): Promise<DecryptResult> {
|
||||||
|
// Worker thread will fallback to in-memory storage.
|
||||||
|
if (storage instanceof InMemoryStorage) {
|
||||||
|
await storage.setAll(config);
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@ -60,6 +68,9 @@ export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
|||||||
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;
|
||||||
|
case 'ofl_en':
|
||||||
|
rt_data = await JooxDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw '不支持此文件格式';
|
throw '不支持此文件格式';
|
||||||
}
|
}
|
||||||
|
44
src/decrypt/joox.ts
Normal file
44
src/decrypt/joox.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import jooxFactory from '@unlock-music/joox-crypto';
|
||||||
|
|
||||||
|
import { DecryptResult } from './entity';
|
||||||
|
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from './utils';
|
||||||
|
|
||||||
|
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
||||||
|
|
||||||
|
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 songId = raw_filename.match(/^(\d+)\s\[mqms\d*]$/i)?.[1];
|
||||||
|
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
|
||||||
|
new Blob([musicDecoded], { type: mime }),
|
||||||
|
raw_filename,
|
||||||
|
ext,
|
||||||
|
songId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
ext: ext,
|
||||||
|
album: album,
|
||||||
|
picture: imgUrl,
|
||||||
|
file: URL.createObjectURL(blob),
|
||||||
|
blob: blob,
|
||||||
|
mime: mime,
|
||||||
|
};
|
||||||
|
}
|
@ -1,21 +1,10 @@
|
|||||||
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
|
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
|
||||||
import {
|
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
|
||||||
AudioMimeType,
|
|
||||||
GetArrayBuffer,
|
|
||||||
GetCoverFromFile,
|
|
||||||
GetImageFromURL,
|
|
||||||
GetMetaFromFile,
|
|
||||||
SniffAudioExt,
|
|
||||||
WriteMetaToFlac,
|
|
||||||
WriteMetaToMp3,
|
|
||||||
} from '@/decrypt/utils';
|
|
||||||
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
|
||||||
import { DecryptQMCWasm } from './qmc_wasm';
|
import { DecryptQMCWasm } from './qmc_wasm';
|
||||||
|
|
||||||
import iconv from 'iconv-lite';
|
|
||||||
import { DecryptResult } from '@/decrypt/entity';
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import { queryAlbumCover } from '@/utils/api';
|
|
||||||
import { QmcDeriveKey } from '@/decrypt/qmc_key';
|
import { QmcDeriveKey } from '@/decrypt/qmc_key';
|
||||||
|
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
||||||
|
|
||||||
interface Handler {
|
interface Handler {
|
||||||
ext: string;
|
ext: string;
|
||||||
@ -72,68 +61,24 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
|||||||
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 });
|
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
|
||||||
|
new Blob([musicDecoded], { type: mime }),
|
||||||
|
raw_filename,
|
||||||
|
ext,
|
||||||
|
);
|
||||||
|
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
|
||||||
for (let metaIdx in musicMeta.native) {
|
|
||||||
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue;
|
|
||||||
if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) {
|
|
||||||
console.warn('try using gbk encoding to decode meta');
|
|
||||||
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk');
|
|
||||||
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk');
|
|
||||||
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
|
|
||||||
|
|
||||||
let imgUrl = GetCoverFromFile(musicMeta);
|
|
||||||
if (!imgUrl) {
|
|
||||||
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
|
|
||||||
if (imgUrl) {
|
|
||||||
const imageInfo = await GetImageFromURL(imgUrl);
|
|
||||||
if (imageInfo) {
|
|
||||||
imgUrl = imageInfo.url;
|
|
||||||
try {
|
|
||||||
const newMeta = { picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(' _ ') };
|
|
||||||
if (ext === 'mp3') {
|
|
||||||
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta);
|
|
||||||
musicBlob = new Blob([musicDecoded], { type: mime });
|
|
||||||
} else if (ext === 'flac') {
|
|
||||||
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta);
|
|
||||||
musicBlob = new Blob([musicDecoded], { type: mime });
|
|
||||||
} else {
|
|
||||||
console.info('writing metadata for ' + ext + ' is not being supported for now');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Error while appending cover image to file ' + e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
title: info.title,
|
title: title,
|
||||||
artist: info.artist,
|
artist: artist,
|
||||||
ext: ext,
|
ext: ext,
|
||||||
album: musicMeta.common.album,
|
album: album,
|
||||||
picture: imgUrl,
|
picture: imgUrl,
|
||||||
file: URL.createObjectURL(musicBlob),
|
file: URL.createObjectURL(blob),
|
||||||
blob: musicBlob,
|
blob: blob,
|
||||||
mime: mime,
|
mime: mime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
|
||||||
const song_query_url = 'https://stats.ixarea.com/apis' + '/music/qq-cover';
|
|
||||||
try {
|
|
||||||
const data = await queryAlbumCover(title, artist, album);
|
|
||||||
return `${song_query_url}/${data.Type}/${data.Id}`;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class QmcDecoder {
|
export class QmcDecoder {
|
||||||
private static readonly BYTE_COMMA = ','.charCodeAt(0);
|
private static readonly BYTE_COMMA = ','.charCodeAt(0);
|
||||||
file: Uint8Array;
|
file: Uint8Array;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
|
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
|
||||||
|
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||||
|
|
||||||
// 检测文件末端使用的缓冲区大小
|
// 检测文件末端使用的缓冲区大小
|
||||||
const DETECTION_SIZE = 40;
|
const DETECTION_SIZE = 40;
|
||||||
@ -6,22 +7,6 @@ const DETECTION_SIZE = 40;
|
|||||||
// 每次处理 2M 的数据
|
// 每次处理 2M 的数据
|
||||||
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
|
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 加密的文件。
|
* 解密一个 QMC2 加密的文件。
|
||||||
*
|
*
|
||||||
|
@ -6,9 +6,13 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
Container,
|
Container,
|
||||||
|
Dialog,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
Footer,
|
Footer,
|
||||||
Icon,
|
Icon,
|
||||||
Image,
|
Image,
|
||||||
|
Input,
|
||||||
Link,
|
Link,
|
||||||
Main,
|
Main,
|
||||||
Notification,
|
Notification,
|
||||||
@ -26,6 +30,10 @@ import 'element-ui/lib/theme-chalk/base.css';
|
|||||||
Vue.use(Link);
|
Vue.use(Link);
|
||||||
Vue.use(Image);
|
Vue.use(Image);
|
||||||
Vue.use(Button);
|
Vue.use(Button);
|
||||||
|
Vue.use(Dialog);
|
||||||
|
Vue.use(Form);
|
||||||
|
Vue.use(FormItem);
|
||||||
|
Vue.use(Input);
|
||||||
Vue.use(Table);
|
Vue.use(Table);
|
||||||
Vue.use(TableColumn);
|
Vue.use(TableColumn);
|
||||||
Vue.use(Main);
|
Vue.use(Main);
|
||||||
|
15
src/utils/MergeUint8Array.ts
Normal file
15
src/utils/MergeUint8Array.ts
Normal file
@ -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;
|
||||||
|
}
|
1
src/utils/__mocks__/qm_meta.ts
Normal file
1
src/utils/__mocks__/qm_meta.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const extractQQMusicMeta = jest.fn();
|
4
src/utils/__mocks__/storage.ts
Normal file
4
src/utils/__mocks__/storage.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const storage = {
|
||||||
|
loadJooxUUID: jest.fn(),
|
||||||
|
saveJooxUUID: jest.fn(),
|
||||||
|
};
|
@ -32,3 +32,82 @@ export async function queryAlbumCover(title: string, artist?: string, album?: st
|
|||||||
const resp = await fetch(`${endpoint}?${params.toString()}`);
|
const resp = await fetch(`${endpoint}?${params.toString()}`);
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TrackInfo {
|
||||||
|
id: number;
|
||||||
|
type: number;
|
||||||
|
mid: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
singer: {
|
||||||
|
id: number;
|
||||||
|
mid: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
type: number;
|
||||||
|
uin: number;
|
||||||
|
}[];
|
||||||
|
album: {
|
||||||
|
id: number;
|
||||||
|
mid: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
time_public: string;
|
||||||
|
pmid: string;
|
||||||
|
};
|
||||||
|
interval: number;
|
||||||
|
index_cd: number;
|
||||||
|
index_album: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SongItemInfo {
|
||||||
|
title: string;
|
||||||
|
content: {
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SongInfoResponse {
|
||||||
|
info: {
|
||||||
|
company: SongItemInfo;
|
||||||
|
genre: SongItemInfo;
|
||||||
|
intro: SongItemInfo;
|
||||||
|
lan: SongItemInfo;
|
||||||
|
pub_time: SongItemInfo;
|
||||||
|
};
|
||||||
|
extras: {
|
||||||
|
name: string;
|
||||||
|
transname: string;
|
||||||
|
subtitle: string;
|
||||||
|
from: string;
|
||||||
|
wikiurl: string;
|
||||||
|
};
|
||||||
|
track_info: TrackInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawQMBatchResponse<T> {
|
||||||
|
code: number;
|
||||||
|
ts: number;
|
||||||
|
start_ts: number;
|
||||||
|
traceid: string;
|
||||||
|
req_1: {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function querySongInfoById(id: string | number): Promise<SongInfoResponse> {
|
||||||
|
const url = `${IXAREA_API_ENDPOINT}/meta/qq-music-raw/${id}`;
|
||||||
|
const result: RawQMBatchResponse<SongInfoResponse> = await fetch(url).then((r) => r.json());
|
||||||
|
if (result.code === 0 && result.req_1.code === 0) {
|
||||||
|
return result.req_1.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('请求信息失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQMImageURLFromPMID(pmid: string, type = 1): string {
|
||||||
|
return `${IXAREA_API_ENDPOINT}/music/qq-cover/${type}/${pmid}`;
|
||||||
|
}
|
||||||
|
147
src/utils/qm_meta.ts
Normal file
147
src/utils/qm_meta.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
import iconv from 'iconv-lite';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GetCoverFromFile,
|
||||||
|
GetImageFromURL,
|
||||||
|
GetMetaFromFile,
|
||||||
|
WriteMetaToFlac,
|
||||||
|
WriteMetaToMp3,
|
||||||
|
AudioMimeType,
|
||||||
|
} from '@/decrypt/utils';
|
||||||
|
import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api';
|
||||||
|
|
||||||
|
interface MetaResult {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
imgUrl: string;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param musicBlob 音乐文件(解密后)
|
||||||
|
* @param name 文件名
|
||||||
|
* @param ext 原始后缀名
|
||||||
|
* @param id 曲目 ID(<code>number</code>类型或纯数字组成的字符串)
|
||||||
|
* @returns Promise
|
||||||
|
*/
|
||||||
|
export async function extractQQMusicMeta(
|
||||||
|
musicBlob: Blob,
|
||||||
|
name: string,
|
||||||
|
ext: string,
|
||||||
|
id?: number | string,
|
||||||
|
): Promise<MetaResult> {
|
||||||
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
|
for (let metaIdx in musicMeta.native) {
|
||||||
|
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue;
|
||||||
|
if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) {
|
||||||
|
console.warn('try using gbk encoding to decode meta');
|
||||||
|
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk');
|
||||||
|
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk');
|
||||||
|
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
try {
|
||||||
|
return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('在线获取曲目信息失败,回退到本地 meta 提取', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = GetMetaFromFile(name, musicMeta.common.title, musicMeta.common.artist);
|
||||||
|
info.artist = info.artist || '';
|
||||||
|
|
||||||
|
let imageURL = GetCoverFromFile(musicMeta);
|
||||||
|
if (!imageURL) {
|
||||||
|
imageURL = await getCoverImage(info.title, info.artist, musicMeta.common.album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist || '',
|
||||||
|
album: musicMeta.common.album || '',
|
||||||
|
imgUrl: imageURL,
|
||||||
|
blob: await writeMetaToAudioFile({
|
||||||
|
title: info.title,
|
||||||
|
artists: info.artist.split(' _ '),
|
||||||
|
ext,
|
||||||
|
imageURL,
|
||||||
|
musicMeta,
|
||||||
|
blob: musicBlob,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetadataFromSongId(
|
||||||
|
id: number | string,
|
||||||
|
ext: string,
|
||||||
|
musicMeta: IAudioMetadata,
|
||||||
|
blob: Blob,
|
||||||
|
): Promise<MetaResult> {
|
||||||
|
const info = await querySongInfoById(id);
|
||||||
|
const imageURL = getQMImageURLFromPMID(info.track_info.album.pmid);
|
||||||
|
const artists = info.track_info.singer.map((singer) => singer.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: info.track_info.title,
|
||||||
|
artist: artists.join('、'),
|
||||||
|
album: info.track_info.album.name,
|
||||||
|
imgUrl: imageURL,
|
||||||
|
|
||||||
|
blob: await writeMetaToAudioFile({
|
||||||
|
title: info.track_info.title,
|
||||||
|
artists,
|
||||||
|
ext,
|
||||||
|
imageURL,
|
||||||
|
musicMeta,
|
||||||
|
blob,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await queryAlbumCover(title, artist, album);
|
||||||
|
return getQMImageURLFromPMID(data.Id, data.Type);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewAudioMeta {
|
||||||
|
title: string;
|
||||||
|
artists: string[];
|
||||||
|
ext: string;
|
||||||
|
|
||||||
|
musicMeta: IAudioMetadata;
|
||||||
|
|
||||||
|
blob: Blob;
|
||||||
|
imageURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMetaToAudioFile(info: NewAudioMeta): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const imageInfo = await GetImageFromURL(info.imageURL);
|
||||||
|
if (!imageInfo) {
|
||||||
|
console.warn('获取图像失败');
|
||||||
|
}
|
||||||
|
const newMeta = { picture: imageInfo?.buffer, title: info.title, artists: info.artists };
|
||||||
|
const buffer = Buffer.from(await info.blob.arrayBuffer());
|
||||||
|
const mime = AudioMimeType[info.ext] || AudioMimeType.mp3;
|
||||||
|
if (info.ext === 'mp3') {
|
||||||
|
return new Blob([WriteMetaToMp3(buffer, newMeta, info.musicMeta)], { type: mime });
|
||||||
|
} else if (info.ext === 'flac') {
|
||||||
|
return new Blob([WriteMetaToFlac(buffer, newMeta, info.musicMeta)], { type: mime });
|
||||||
|
} else {
|
||||||
|
console.info('writing metadata for ' + info.ext + ' is not being supported for now');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error while appending cover image to file ' + e);
|
||||||
|
}
|
||||||
|
return info.blob;
|
||||||
|
}
|
3
src/utils/storage.ts
Normal file
3
src/utils/storage.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import storageFactory from './storage/StorageFactory';
|
||||||
|
|
||||||
|
export const storage = storageFactory();
|
17
src/utils/storage/BaseStorage.ts
Normal file
17
src/utils/storage/BaseStorage.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const KEY_PREFIX = 'um.conf.';
|
||||||
|
const KEY_JOOX_UUID = `${KEY_PREFIX}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 abstract getAll(): Promise<Record<string, any>>;
|
||||||
|
public abstract setAll(obj: Record<string, any>): Promise<void>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
43
src/utils/storage/BrowserNativeStorage.ts
Normal file
43
src/utils/storage/BrowserNativeStorage.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import BaseStorage, { KEY_PREFIX } from './BaseStorage';
|
||||||
|
|
||||||
|
export default class BrowserNativeStorage extends BaseStorage {
|
||||||
|
public static get works() {
|
||||||
|
return typeof localStorage !== 'undefined' && localStorage.getItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async load<T>(name: string, defaultValue: T): Promise<T> {
|
||||||
|
const result = localStorage.getItem(name);
|
||||||
|
if (result === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(result);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save<T>(name: string, value: T): Promise<void> {
|
||||||
|
localStorage.setItem(name, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(): Promise<Record<string, any>> {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(localStorage)) {
|
||||||
|
if (key.startsWith(KEY_PREFIX)) {
|
||||||
|
try {
|
||||||
|
Object.assign(result, { [key]: JSON.parse(value) });
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAll(obj: Record<string, any>): Promise<void> {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
await this.save(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
src/utils/storage/ChromeExtensionStorage.ts
Normal file
47
src/utils/storage/ChromeExtensionStorage.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import BaseStorage, { KEY_PREFIX } from './BaseStorage';
|
||||||
|
|
||||||
|
declare var chrome: any;
|
||||||
|
|
||||||
|
export default class ChromeExtensionStorage extends BaseStorage {
|
||||||
|
static get works(): boolean {
|
||||||
|
return typeof chrome !== 'undefined' && Boolean(chrome?.storage?.local?.set);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async load<T>(name: string, defaultValue: T): Promise<T> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get({ [name]: defaultValue }, (result: any) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(result, name)) {
|
||||||
|
resolve(result[name]);
|
||||||
|
} else {
|
||||||
|
resolve(defaultValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save<T>(name: string, value: T): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.set({ [name]: value }, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(): Promise<Record<string, any>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get(null, (obj: Record<string, any>) => {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (key.startsWith(KEY_PREFIX)) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAll(obj: Record<string, any>): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.set(obj, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
32
src/utils/storage/InMemoryStorage.ts
Normal file
32
src/utils/storage/InMemoryStorage.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import BaseStorage from './BaseStorage';
|
||||||
|
|
||||||
|
export default class InMemoryStorage extends BaseStorage {
|
||||||
|
private values = new Map<string, any>();
|
||||||
|
protected async load<T>(name: string, defaultValue: T): Promise<T> {
|
||||||
|
if (this.values.has(name)) {
|
||||||
|
return this.values.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save<T>(name: string, value: T): Promise<void> {
|
||||||
|
this.values.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(): Promise<Record<string, any>> {
|
||||||
|
const result = {};
|
||||||
|
this.values.forEach((value, key) => {
|
||||||
|
Object.assign(result, {
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAll(obj: Record<string, any>): Promise<void> {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
this.values.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
src/utils/storage/StorageFactory.ts
Normal file
13
src/utils/storage/StorageFactory.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import BaseStorage from './BaseStorage';
|
||||||
|
import BrowserNativeStorage from './BrowserNativeStorage';
|
||||||
|
import ChromeExtensionStorage from './ChromeExtensionStorage';
|
||||||
|
import InMemoryStorage from './InMemoryStorage';
|
||||||
|
|
||||||
|
export default function storageFactory(): BaseStorage {
|
||||||
|
if (ChromeExtensionStorage.works) {
|
||||||
|
return new ChromeExtensionStorage();
|
||||||
|
} else if (BrowserNativeStorage.works) {
|
||||||
|
return new BrowserNativeStorage();
|
||||||
|
}
|
||||||
|
return new InMemoryStorage();
|
||||||
|
}
|
@ -10,6 +10,13 @@
|
|||||||
</el-radio>
|
</el-radio>
|
||||||
</el-row>
|
</el-row>
|
||||||
<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-download" plain @click="handleDownloadAll">下载全部</el-button>
|
||||||
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
|
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
|
||||||
|
|
||||||
@ -35,6 +42,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import FileSelector from '@/component/FileSelector';
|
import FileSelector from '@/component/FileSelector';
|
||||||
import PreviewTable from '@/component/PreviewTable';
|
import PreviewTable from '@/component/PreviewTable';
|
||||||
|
import ConfigDialog from '@/component/ConfigDialog';
|
||||||
|
|
||||||
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
|
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@ -42,9 +51,11 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FileSelector,
|
FileSelector,
|
||||||
PreviewTable,
|
PreviewTable,
|
||||||
|
ConfigDialog,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
showConfigDialog: false,
|
||||||
tableData: [],
|
tableData: [],
|
||||||
playing_url: '',
|
playing_url: '',
|
||||||
playing_auto: false,
|
playing_auto: false,
|
||||||
@ -103,6 +114,9 @@ export default {
|
|||||||
});
|
});
|
||||||
this.tableData = [];
|
this.tableData = [];
|
||||||
},
|
},
|
||||||
|
handleDecryptionConfig() {
|
||||||
|
this.showConfigDialog = true;
|
||||||
|
},
|
||||||
handleDownloadAll() {
|
handleDownloadAll() {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let c = setInterval(() => {
|
let c = setInterval(() => {
|
||||||
|
Loading…
Reference in New Issue
Block a user