73 Commits

Author SHA1 Message Date
MengYX
8ccdf9a762 chore: update deps & bump version
(cherry picked from commit c48dbd8a350d0df22e997ccf9fbdc4e889ddd379)
2022-01-09 10:47:34 +08:00
MengYX
69b497a4ca docs: update README.md
(cherry picked from commit 49c53a9c65dc8aa37a9b7413ab937854ecc1c20a)
2022-01-09 10:47:33 +08:00
MengYX
8894b5379d refactor: change common -> index
(cherry picked from commit eb6be0d9d13a0834b6a7ada9aab846cfbbdfd664)
2022-01-09 10:46:57 +08:00
MengYX
c4177be7df fix(QMCv2): overflow error in js decoder
(cherry picked from commit 191ac6a932efb290f49e2824839db20ac6ff47ca)
2022-01-09 10:46:55 +08:00
MengYX
edffe53495 fix #222
(cherry picked from commit 53127de96851ef24f774169ae5e55274f800bd99)
2022-01-09 10:46:54 +08:00
MengYX
152b695b10 fix: declare radix in parseInt
Co-authored-by: Jixun Wu <5713045+jixunmoe@users.noreply.github.com>
(cherry picked from commit eee7d7aedc37797820184c12f6954df23e4b98b8)
2022-01-09 10:46:48 +08:00
鲁树人
a2210becc5 fix(QMCv2): Fail gracefully when WebAssembly loader failed.
(cherry picked from commit 700574fb8efd1a1057bc16a053e5015dcb2e3044)
2022-01-09 10:46:46 +08:00
鲁树人
f6c34cd7ba feat(QMCv2): Allow extraction of songId from QMC2-wasm
(cherry picked from commit 9ca2d852ce713255caeb8424a2724cb936434f18)
2022-01-09 10:46:45 +08:00
鲁树人
0af8a0d714 chore: bump qmc2-crypto to v0.0.6-R1
(cherry picked from commit e29b36229e3f550a2fe8dd9d3ae48826f6229ec7)
2022-01-09 10:46:43 +08:00
MengYX
2e0b24a6fc feature: use online info to correct qmc meta
(cherry picked from commit 7c62f35adb268509f543d67b4a36f49ada3ae206)
2022-01-09 10:46:42 +08:00
MengYX
9edb12b008 fix #142: remove default mp3 cover description
(cherry picked from commit 4bca64b4b8f4be02222de2fa5f6db0382855fd23)
2022-01-09 10:46:35 +08:00
MengYX
96fccaeec7 fix: api path & docker image name
(cherry picked from commit ce3de22d0e25f8cdf13e455baaae657bf61ee56a)
2022-01-09 10:46:21 +08:00
鲁树人
62cd276c5d test(joox): Added basic sanity test for joox encryption.
(cherry picked from commit 48b8194363264a0276006deaa3c956970a543627)
2022-01-09 10:46:19 +08:00
鲁树人
18a8dbfaa4 feat(joox): Fetch meta data from API
(cherry picked from commit 4af1a38334cfc51ce64dd509f2dff694f78010f6)
2022-01-09 10:46:18 +08:00
鲁树人
7fac4c60a5 doc: reforamtted & updated content in readme
(cherry picked from commit 68782f0ec570f7e4ec7ae5adc0bcd7da7a0d64b9)
2022-01-09 10:46:17 +08:00
鲁树人
556b69d6ef fix: avoid "ArtiomTr/jest-coverage-report-action" when running from a fork.
(cherry picked from commit 95df64516c59f4bbbffa21625af8f9be13da01af)
2022-01-09 10:45:18 +08:00
鲁树人
c336ac4d05 refactor: move ruby to custom vue component
(cherry picked from commit ea99d38a920850b0d4dbaa7352f57ebf13bbbee6)
2022-01-09 10:45:17 +08:00
鲁树人
6d7f4512c4 chore: remove left-over debugger statement
(cherry picked from commit afab80505e343830e7b20a8073d21bdbfc7e3502)
2022-01-09 10:45:16 +08:00
鲁树人
de1c686231 fix: form validation on input change
(cherry picked from commit c20ce54dacfe4ccc31974a9a9092938ed47db4bb)
2022-01-09 10:45:14 +08:00
鲁树人
290986e546 feat(config): better config ui
- JOOX Music UUID label + description
- Not full screen anymore

(cherry picked from commit 8a323f9dbbd17515f53826023a565112acaed90b)
2022-01-09 10:45:06 +08:00
鲁树人
a69ed4f3ce feat(joox): re-use QM meta extraction code
(cherry picked from commit 2e946e6e30e02085018e868b7857acb62a1a0b08)
2022-01-09 10:45:04 +08:00
鲁树人
85544cd09a refactor(qmc): extract qm meta code to utils
(cherry picked from commit b6497e2bd3679e0e62bd6a90bdac16fa7c7f1b4e)
2022-01-09 10:45:02 +08:00
鲁树人
0715eeea0b fix: only pass over config settings
(cherry picked from commit 3884158f06b71907f004d7a2b4df53e3e486983b)
2022-01-09 10:45:01 +08:00
鲁树人
eaf457e6a0 feat(storage): Pass over config to worker thread on decryption call
(cherry picked from commit 36d616398eac4e8d51863863fa5205fe1c91267f)
2022-01-09 10:44:59 +08:00
鲁树人
d8b362efbe refactor: storage factory + singleton
- Make storage easier.

(cherry picked from commit ed84a4732d7dd3ce6b2c22f30553ab5c59f85dbb)
2022-01-09 10:44:57 +08:00
鲁树人
8673adfda6 fix: storage read/write in chrome extension
(cherry picked from commit bae9a7fec0c98807b3c5c3598f321135ccf6c9d5)
2022-01-09 10:44:56 +08:00
鲁树人
37da3318c8 fix: add missing permission for chrome storage
(cherry picked from commit 3fb0e1eb0f80cdb84fce6c2eb2a12a028beb1f0b)
2022-01-09 10:44:54 +08:00
鲁树人
2855f72c03 chore: bump to 0.0.1-R4
(cherry picked from commit 2da37f984a8ed4ca369e2efecb2da5d71976c93e)
2022-01-09 10:44:52 +08:00
鲁树人
fd54617c5c fix: crash due to chrome been undefined
(cherry picked from commit 53a2073cb482fc9deef7aa0ddf45447c6971d819)
2022-01-09 10:44:50 +08:00
鲁树人
c895c5c069 chore: bump joox-crypto dependency
(cherry picked from commit b46d9fa720a9193ae51b5954e2e34c875e1cc897)
2022-01-09 10:44:49 +08:00
鲁树人
1e7116a3a9 feat: add basic joox support
(cherry picked from commit 699333ca06526d747a7eb4a188e896de81e9f014)
2022-01-09 10:44:36 +08:00
MengYX
9add76c060 fix(ci): test coverage annotation failed
(cherry picked from commit 058985de4f003e2fbdfc4261e2d172c1f9c1c4db)
2022-01-09 10:44:32 +08:00
MengYX
f7a215103a pretty: ignore matrix
(cherry picked from commit edc4e4864b31c32e5860b3a5c840657be6cc4154)
2022-01-09 10:44:28 +08:00
MengYX
cd5fba5e4e chore: remove unused api
(cherry picked from commit 3727f67e407807de33be64905b927561aaf1c10f)
2022-01-09 10:44:22 +08:00
MengYX
76dd78130a all: format with prettier
(cherry picked from commit cad5b4d7deba4fbe4a40a17306ce49d3b2f13139)
2022-01-09 10:44:16 +08:00
MengYX
19486d4d34 maintenance: add prettier
(cherry picked from commit 559be402c940b7b31bdb2567c23ff17251aabe04)
2022-01-09 10:38:33 +08:00
MengYX
8ce76fa7dc fix(QMCv2): cipher should determine by key size
(cherry picked from commit dba63f212cbf9351e5dc16870eb32ae582db2867)
2022-01-09 10:38:27 +08:00
MengYX
632651b371 chore(QMCv2): fix code style
(cherry picked from commit 87138718549bdec014752ba43dcd5997aaf29137)
2022-01-09 10:38:20 +08:00
MengYX
979168b68f feat(QMCv2): use js decoder
(cherry picked from commit c24e5d29733cfa771dd41ae40032029c6bbb9186)
2022-01-09 10:38:18 +08:00
MengYX
1ab05bb509 feat(QMCv2): add decoder
(cherry picked from commit 29ac94d1fe52e666fda619f8716d2bc0b120a9ee)
2022-01-09 10:38:16 +08:00
MengYX
910b00529e feat(QMCv2): add rc4 cipher
(cherry picked from commit 6b5b4d3bf5f6285e908808d48dee4e2e4ae8c3a2)
2022-01-09 10:38:14 +08:00
MengYX
23b096512e feat(QMCv2): add key decrypt
(cherry picked from commit a9aaa40ec48a75967882ef95951bf4f7fccf7a9d)
2022-01-09 10:38:13 +08:00
MengYX
fe89710968 feat(QMCv2): add map cipher
(cherry picked from commit 7306bf031f8bc07168197c00e332bf89c8d611dd)
2022-01-09 10:38:11 +08:00
MengYX
8c11f47aa4 test(QMCv2): coverage standard TEA cipher
(cherry picked from commit c2c89a423ffffc06fb43c86d4714bb32d1936c3e)
2022-01-09 10:38:09 +08:00
MengYX
769d3392f8 feat(QMCv2): add standard TEA cipher
(cherry picked from commit 24422b216a15319d90799d4f8f54453c8efd5c34)
2022-01-09 10:38:08 +08:00
MengYX
9c3e39502a fix(extension): version string must be numbers and dots
(cherry picked from commit 3fd35b5d30037a6e156fdb75ca4124837b37d658)
2021-12-16 10:46:20 +08:00
MengYX
5d8a726746 fix: ci
(cherry picked from commit f9f5e32b449c9268cc07f7787587f417d70f08c9)
2021-12-16 09:51:28 +08:00
MengYX
e4c574465c maintenance: update ci
(cherry picked from commit 525ddfae314f05e1d9a7b67cabcc974b32a503b4)
2021-12-16 09:51:23 +08:00
MengYX
8fb9dc029a maintenance: update ci
(cherry picked from commit 10e35c5d3e4391e22fd005f04ab7be0e503c971a)
2021-12-16 09:51:21 +08:00
MengYX
8151c79652 chore: bump version
(cherry picked from commit 12e3f91a1e9a4d681633d531af22b5e385dbe470)
2021-12-16 09:51:19 +08:00
MengYX
aa6476bb2c maintenance: update ci
(cherry picked from commit cb92eed9b135c04a17389f48997c18ba81e60c3a)
2021-12-16 09:51:17 +08:00
MengYX
0f6dc53e54 maintenance: update ci
(cherry picked from commit 3960ea7d591a199c188e764b26d0840ccae1c322)
2021-12-16 09:51:16 +08:00
MengYX
58dfeee960 maintenance: remove fix-compatibility.js
(cherry picked from commit af20e8a6970ec7f08799389ac9ce897d1cc822e0)
2021-12-16 09:51:15 +08:00
MengYX
b4068e2edb feat: use static cipher instead of mask
(cherry picked from commit cd6b84ad7eed489f9bcbd72d847cd4d704052b0c)
2021-12-16 09:51:07 +08:00
鲁树人
cd3f4d8c22 chore: add eol at the end of qmcv2.ts.
(cherry picked from commit 9470f2ca8706d602c6d073012d4c3fc6aec7da77)
2021-12-16 09:50:20 +08:00
鲁树人
522f4e9f67 chore: (redone) upgrade qmc2-crypto to 0.0.5-R4
- Remove the use of `new Function` in emscripten generated code.
- This commit is a clean commit that does the same thing as 3b88d168b660f780824016e4d23241d1fc766e39

(cherry picked from commit bdd60bc502ace1116698ff16357001bfb7608a43)
2021-12-16 09:49:56 +08:00
鲁树人
439003f021 Revert "chore: upgrade qmc2-crypto to 0.0.5-R4"
This reverts commit 3b88d168b660f780824016e4d23241d1fc766e39.

It generates unexpected large diff in package-lock.json.

(cherry picked from commit 0f3cd9b67fbc0f91da5272eb60301e09e4fc6de3)
2021-12-16 09:49:34 +08:00
鲁树人
d6aa88f371 chore: upgrade qmc2-crypto to 0.0.5-R4
- Remove the use of `new Function` in emscripten generated code.

(cherry picked from commit 3b88d168b660f780824016e4d23241d1fc766e39)
2021-12-16 09:49:10 +08:00
鲁树人
fb33e80484 fix: treat qmcflac/qmcogg as QMCv2 and fallback to QMCv1
(cherry picked from commit 41e588e9864801897fa13eb96a1764baaa5a4ab5)
2021-12-16 09:48:52 +08:00
鲁树人
d199647308 refactor: remove suppressed qmc mask methods / constants
(cherry picked from commit 5d48b28a949cbd42f24781a69124d7aa521e51c1)
2021-12-16 09:48:31 +08:00
鲁树人
733c1721ed refactor: restore support for QMCv1.
(cherry picked from commit 19239f182d71e2e4362309f08706a91c00bb6bd1)
2021-12-16 09:48:30 +08:00
鲁树人
dbb5472f96 chore: update supported ext list
(cherry picked from commit bdab51bde327244a105fff5c2086911b275b2259)
2021-12-16 09:48:13 +08:00
鲁树人
1111aaf3b7 chore: Use QMC2-Crypto with embedded WASM build from 0.0.5-R3
(cherry picked from commit 9448b497ed6b80e41f0e9f731f1ffa1e56fb149a)
2021-12-16 09:48:06 +08:00
鲁树人
f80cf29657 fix: patch threads to work with production build
(cherry picked from commit 4da56bb0fe509c4cb0c4bb6e560b4383f185bf45)
2021-12-16 09:47:41 +08:00
鲁树人
0a52d2a20b feat(qmcv2): Experiment with qmc2-crypto
(cherry picked from commit c8eb1bc481347efb6d35e9122e17e624bde18772)
2021-12-16 09:47:40 +08:00
MengYX
ada078df19 maintenance: add jest as unit test 2021-12-14 15:39:52 +08:00
MengYX
8facd65834 chore: update deps 2021-12-14 14:57:48 +08:00
MengYX
a6367401ae Merge pull request #184 from lvzx123/patch-1
Now it is 2021!
2021-09-25 15:27:57 +08:00
lvzx123
565ee37a8b Now it is 2021!
大人,时代变了
2021-09-25 08:56:07 +08:00
MengYX
aab611bff5 bump version 2021-08-27 10:01:05 +08:00
MengYX
942e43fa16 fix #169 2021-08-25 01:04:46 +08:00
MengYX
84be57a903 fix #179 2021-08-24 23:55:44 +08:00
MengYX
a6a3c02538 fix #179 2021-08-24 23:13:19 +08:00
90 changed files with 29872 additions and 5271 deletions

View File

@@ -1,7 +1,10 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Test Build name: Test Build
on: on:
push: push:
paths: paths:
- ".github/workflows/*"
- "**/*.js" - "**/*.js"
- "**/*.ts" - "**/*.ts"
- "**/*.vue" - "**/*.vue"
@@ -20,8 +23,23 @@ on:
- "package.json" - "package.json"
jobs: jobs:
test-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- run: npm ci
# 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:
github-token: ${{ secrets.GITHUB_TOKEN }}
annotations: none
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
@@ -37,40 +55,30 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Use Node.js 14.x - name: Use Node.js 16.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "14" node-version: "16"
- name: Install Dependencies - name: Install Dependencies
run: | run: npm ci
npm ci
npm run fix-compatibility
- name: Build - name: Build
env: run: npm run build ${{ matrix.BUILD_ARGS }}
GZIP: "--best"
run: |
npm run build ${{ matrix.BUILD_ARGS }}
tar -czvf dist.tar.gz -C ./dist .
- name: Build Extension
if: ${{ matrix.BUILD_EXTENSION }}
run: |
npm run make-extension
cd dist
zip -rJ9 ../extension.zip *
cd ..
- name: Publish artifact - name: Publish artifact
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: unlock-music-${{ matrix.build }}.tar.gz name: ${{ matrix.build }}
path: ./dist.tar.gz path: ./dist
- name: Build Extension
if: ${{ matrix.BUILD_EXTENSION }}
run: npm run make-extension
- name: Publish artifact - Extension - name: Publish artifact - Extension
if: ${{ matrix.BUILD_EXTENSION }} if: ${{ matrix.BUILD_EXTENSION }}
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: extension.zip name: extension
path: ./extension.zip path: ./dist

View File

@@ -11,15 +11,13 @@ jobs:
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Use Node.js 14.x - name: Use Node.js 16.x
uses: actions/setup-node@v2 uses: actions/setup-node@v2
with: with:
node-version: "14" node-version: "16"
- name: Install Dependencies - name: Install Dependencies
run: | run: npm ci
npm ci
npm run fix-compatibility
- name: Build Legacy - name: Build Legacy
env: env:

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.DS_Store .DS_Store
node_modules node_modules
/dist /dist
/coverage
# local env files # local env files
.env.local .env.local

42
.prettierrc.js Normal file
View File

@@ -0,0 +1,42 @@
// .prettierrc.js
module.exports = {
// 一行最多 120 字符
printWidth: 120,
// 使用 2 个空格缩进
tabWidth: 2,
// 不使用缩进符,而使用空格
useTabs: false,
// 行尾需要有分号
semi: true,
// 使用单引号
singleQuote: true,
// 对象的 key 仅在必要时用引号
quoteProps: 'as-needed',
// jsx 不使用单引号,而使用双引号
jsxSingleQuote: false,
// 末尾需要有逗号
trailingComma: 'all',
// 大括号内的首尾需要空格
bracketSpacing: true,
// jsx 标签的反尖括号需要换行
bracketSameLine: false,
// 箭头函数,只有一个参数的时候,也需要括号
arrowParens: 'always',
// 每个文件格式化的范围是文件的全部内容
rangeStart: 0,
rangeEnd: Infinity,
// 不需要写文件开头的 @prettier
requirePragma: false,
// 不需要自动在文件开头插入 @prettier
insertPragma: false,
// 使用默认的折行标准
proseWrap: 'preserve',
// 根据显示样式决定 html 要不要折行
htmlWhitespaceSensitivity: 'css',
// vue 文件中的 script 和 style 内不用缩进
vueIndentScriptAndStyle: false,
// 换行符使用 lf
endOfLine: 'lf',
// 格式化嵌入的内容
embeddedLanguageFormatting: 'auto',
};

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019-2020 MengYX Copyright (c) 2019-2021 MengYX
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View File

@@ -1,42 +1,49 @@
# 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 MusicCLI版本正在开发中 - Unlock MusicCLI 版本可以在 [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 音乐格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
- [x] QQ音乐新格式 (实验性支持) - [x] <ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)
- [x] .mflac
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
- [x] 网易云音乐格式 (.ncm) - [x] 网易云音乐格式 (.ncm)
- [x] 补全ncm的ID3/FlacMeta信息 - [x] 虾米音乐格式 (.xm)
- [x] 虾米音乐格式 (.xm) (测试阶段) - [x] 酷我音乐格式 (.kwm)
- [x]音乐格式 (.kwm) (测试阶段) - [x]音乐格式 (.kgm/.vpr) ([CLI 版本][kgm_cli])
- [x] 酷狗音乐格式 (
.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和封面图片
## 使用方法 ## 使用方法
@@ -48,11 +55,11 @@
### 使用已构建版本 ### 使用已构建版本
- 从[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 镜像
```shell ```shell
docker run --name unlock-music -d -p 8080:80 ix64/unlock-music docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
@@ -61,11 +68,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
```

View File

@@ -1,6 +1,7 @@
module.exports = { module.exports = {
presets: [ presets: [
'@vue/app' '@vue/app',
'@babel/preset-typescript'
], ],
plugins: [ plugins: [
["component", { ["component", {

View File

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

8
jest.config.js Normal file
View File

@@ -0,0 +1,8 @@
module.exports = {
setupFilesAfterEnv: [
'./src/__test__/setup_jest.js'
],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1'
}
};

View File

@@ -15,9 +15,11 @@ const manifest = JSON.parse(manifestRaw)
const pkgRaw = fs.readFileSync("./package.json", "utf-8") const pkgRaw = fs.readFileSync("./package.json", "utf-8")
const pkg = JSON.parse(pkgRaw) const pkg = JSON.parse(pkgRaw)
ver_str = pkg["version"] verExt = pkg["version"]
if (ver_str.startsWith("v")) ver_str = ver_str.slice(1) if (verExt.startsWith("v")) verExt = verExt.slice(1)
manifest["version"] = ver_str if (verExt.includes("-")) verExt = verExt.split("-")[0]
manifest["version"] = `${verExt}.${pkg["ext_build"]}`
manifest["version_name"] = pkg["version"]
fs.writeFileSync("./dist/manifest.json", JSON.stringify(manifest), "utf-8") fs.writeFileSync("./dist/manifest.json", JSON.stringify(manifest), "utf-8")
console.log("Write: manifest.json") console.log("Write: manifest.json")

30377
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,8 @@
{ {
"name": "unlock-music", "name": "unlock-music",
"version": "v1.9.0", "version": "v1.10.0",
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构", "ext_build": 0,
"updateInfo": "重写QMC解锁完全支持.mflac*/.mgg*; 支持JOOX解锁",
"license": "MIT", "license": "MIT",
"description": "Unlock encrypted music file in browser.", "description": "Unlock encrypted music file in browser.",
"repository": { "repository": {
@@ -10,12 +11,18 @@
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "patch-package",
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"fix-compatibility": "node ./src/fix-compatibility.js", "test": "jest",
"pretty": "prettier --write src/{**/*,*}.{js,ts,jsx,tsx,vue}",
"pretty:check": "prettier --check src/{**/*,*}.{js,ts,jsx,tsx,vue}",
"make-extension": "node ./make-extension.js" "make-extension": "node ./make-extension.js"
}, },
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.16.5",
"@jixun/qmc2-crypto": "^0.0.6-R1",
"@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",
@@ -24,23 +31,28 @@
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"jimp": "^0.16.1", "jimp": "^0.16.1",
"metaflac-js": "^1.0.5", "metaflac-js": "^1.0.5",
"music-metadata-browser": "^2.4.3", "music-metadata": "7.9.0",
"music-metadata-browser": "2.2.7",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"threads": "^1.6.5", "threads": "^1.6.5",
"vue": "^2.6.14" "vue": "^2.6.14"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.0.2", "@types/crypto-js": "^4.0.2",
"@types/jest": "^27.0.3",
"@vue/cli-plugin-babel": "^4.5.13", "@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-pwa": "^4.5.13", "@vue/cli-plugin-pwa": "^4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13", "@vue/cli-plugin-typescript": "^4.5.13",
"@vue/cli-service": "^4.5.13", "@vue/cli-service": "^4.5.13",
"babel-plugin-component": "^1.1.1", "babel-plugin-component": "^1.1.1",
"node-sass": "^5.0.0", "jest": "^27.4.5",
"patch-package": "^6.4.7",
"prettier": "2.5.1",
"sass": "^1.38.1",
"sass-loader": "^10.2.0", "sass-loader": "^10.2.0",
"semver": "^7.3.5", "semver": "^7.3.5",
"threads-plugin": "^1.4.0", "threads-plugin": "^1.4.0",
"typescript": "~4.1.6", "typescript": "^4.5.4",
"vue-cli-plugin-element": "^1.0.1", "vue-cli-plugin-element": "^1.0.1",
"vue-template-compiler": "^2.6.14" "vue-template-compiler": "^2.6.14"
} }

View File

@@ -0,0 +1,11 @@
diff --git a/node_modules/threads/worker.mjs b/node_modules/threads/worker.mjs
index c53ac7d..619007b 100644
--- a/node_modules/threads/worker.mjs
+++ b/node_modules/threads/worker.mjs
@@ -1,4 +1,5 @@
-import WorkerContext from "./dist/worker/index.js"
+// Workaround: use of import seems to break minifier.
+const WorkerContext = require("./dist/worker/index.js")
export const expose = WorkerContext.expose
export const registerSerializer = WorkerContext.registerSerializer

View File

@@ -1,85 +1,87 @@
<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>
<a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>({{ version }}) <a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>({{ version }})
移除已购音乐的加密保护 移除已购音乐的加密保护
<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>
开放源代码 开放源代码
</el-row> </el-row>
</el-footer> </el-footer>
</el-container> </el-container>
</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() {
return {
version: config.version,
};
},
created() {
this.$nextTick(() => this.finishLoad());
},
methods: {
async finishLoad() {
const mask = document.getElementById('loader-mask');
if (!!mask) mask.remove();
let updateInfo;
try {
updateInfo = await checkUpdate(this.version);
} catch (e) {
console.warn('check version info failed', e);
}
if (
updateInfo &&
process.env.NODE_ENV === 'production' &&
(updateInfo.HttpsFound || (updateInfo.Found && window.location.protocol !== 'https:'))
) {
this.$notify.warning({
title: '发现更新',
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
dangerouslyUseHTMLString: true,
duration: 15000,
position: 'top-left',
});
} else {
this.$notify.info({
title: '离线使用',
message: `我们使用PWA技术无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left',
});
}
}, },
data() { },
return { };
version: config.version,
}
},
created() {
this.$nextTick(() => this.finishLoad());
},
methods: {
async finishLoad() {
const mask = document.getElementById("loader-mask");
if (!!mask) mask.remove();
let updateInfo;
try {
updateInfo = await checkUpdate(this.version)
} catch (e) {
console.warn("check version info failed", e)
}
if ((updateInfo && process.env.NODE_ENV === 'production') && (updateInfo.HttpsFound ||
(updateInfo.Found && window.location.protocol !== "https:"))) {
this.$notify.warning({
title: '发现更新',
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
dangerouslyUseHTMLString: true,
duration: 15000,
position: 'top-left'
});
} else {
this.$notify.info({
title: '离线使用',
message: `我们使用PWA技术无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left'
});
}
}
},
}
</script> </script>
<style lang="scss"> <style lang="scss">
@import "scss/unlock-music"; @import 'scss/unlock-music';
</style> </style>

View File

@@ -0,0 +1,2 @@
// Polyfill for node.
global.Blob = global.Blob || require("node:buffer").Blob;

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

View File

@@ -1,99 +1,91 @@
<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" <div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
:show-file-list="false" <div slot="tip" class="el-upload__tip">
action="" <div>
drag 仅在浏览器内对文件进行解锁无需消耗流量
multiple> <el-tooltip effect="dark" placement="top-start">
<i class="el-icon-upload"/> <div slot="content">算法在源代码中已经提供所有运算都发生在本地</div>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div> <i class="el-icon-info" style="font-size: 12px" />
<div slot="tip" class="el-upload__tip"> </el-tooltip>
<div> </div>
仅在浏览器内对文件进行解锁无需消耗流量 <div>
<el-tooltip effect="dark" placement="top-start"> 工作模式: {{ parallel ? '多线程 Worker' : '单线程 Queue' }}
<div slot="content"> <el-tooltip effect="dark" placement="top-start">
算法在源代码中已经提供所有运算都发生在本地 <div slot="content">
</div> 将此工具部署在HTTPS环境下可以启用Web Worker特性<br />
<i class="el-icon-info" style="font-size: 12px"/> 从而更快的利用并行处理完成解锁
</el-tooltip> </div>
</div> <i class="el-icon-info" style="font-size: 12px" />
<div> </el-tooltip>
工作模式: {{ parallel ? "多线程 Worker" : "单线程 Queue" }} </div>
<el-tooltip effect="dark" placement="top-start"> </div>
<div slot="content"> <transition name="el-fade-in"
将此工具部署在HTTPS环境下可以启用Web Worker特性<br/> ><!--todo: add delay to animation-->
从而更快的利用并行处理完成解锁 <el-progress
</div> v-show="progress_show"
<i class="el-icon-info" style="font-size: 12px"/> :format="progress_string"
</el-tooltip> :percentage="progress_value"
</div> :stroke-width="16"
</div> :text-inside="true"
<transition name="el-fade-in"><!--todo: add delay to animation--> style="margin: 16px 6px 0 6px"
<el-progress ></el-progress>
v-show="progress_show" :format="progress_string" :percentage="progress_value" </transition>
:stroke-width="16" :text-inside="true" </el-upload>
style="margin: 16px 6px 0 6px"
></el-progress>
</transition>
</el-upload>
</template> </template>
<script> <script>
import {spawn, Worker, Pool} from "threads" import { spawn, Worker, Pool } from 'threads';
import {CommonDecrypt} from "@/decrypt/common.ts"; import { Decrypt } from '@/decrypt';
import {DecryptQueue} from "@/utils/utils"; import { DecryptQueue } from '@/utils/utils';
import { storage } from '@/utils/storage';
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: {
progress_value() {
return this.task_all ? (this.task_finished / this.task_all) * 100 : 0;
}, },
computed: { progress_show() {
progress_value() { return this.task_all !== this.task_finished;
return this.task_all ? this.task_finished / this.task_all * 100 : 0
},
progress_show() {
return this.task_all !== this.task_finished
}
}, },
mounted() { },
if (window.Worker && window.location.protocol !== "file:" && process.env.NODE_ENV === 'production') { mounted() {
console.log("Using Worker Pool") if (window.Worker && window.location.protocol !== 'file:' && process.env.NODE_ENV === 'production') {
this.queue = Pool( console.log('Using Worker Pool');
() => spawn(new Worker('@/utils/worker.ts')), this.queue = Pool(() => spawn(new Worker('@/utils/worker.ts')), navigator.hardwareConcurrency || 1);
navigator.hardwareConcurrency || 1 this.parallel = true;
) } else {
this.parallel = true console.log('Using Queue in Main Thread');
} else {
console.log("Using Queue in Main Thread")
}
},
methods: {
progress_string() {
return `${this.task_finished} / ${this.task_all}`
},
async addFile(file) {
this.task_all++
this.queue.queue(async (dec = CommonDecrypt) => {
console.log("start handling", file.name)
try {
this.$emit("success", await dec(file));
} catch (e) {
console.error(e)
this.$emit("error", e, file.name)
} finally {
this.task_finished++
}
})
},
} }
} },
methods: {
progress_string() {
return `${this.task_finished} / ${this.task_all}`;
},
async addFile(file) {
this.task_all++;
this.queue.queue(async (dec = Decrypt) => {
console.log('start handling', file.name);
try {
this.$emit('success', await dec(file, await storage.getAll()));
} catch (e) {
console.error(e);
this.$emit('error', e, file.name);
} finally {
this.task_finished++;
}
});
},
},
};
</script> </script>

View File

@@ -1,71 +1,62 @@
<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>
<div slot="error" class="image-slot el-image__error"> </el-image>
暂无封面 </template>
</div> </el-table-column>
</el-image> <el-table-column label="歌曲">
</template> <template #default="scope">
</el-table-column> <span>{{ scope.row.title }}</span>
<el-table-column label="歌曲"> </template>
<template #default="scope"> </el-table-column>
<span>{{ scope.row.title }}</span> <el-table-column label="歌手">
</template> <template #default="scope">
</el-table-column> <p>{{ scope.row.artist }}</p>
<el-table-column label="歌手"> </template>
<template #default="scope"> </el-table-column>
<p>{{ scope.row.artist }}</p> <el-table-column label="专辑">
</template> <template #default="scope">
</el-table-column> <p>{{ scope.row.album }}</p>
<el-table-column label="专辑"> </template>
<template #default="scope"> </el-table-column>
<p>{{ scope.row.album }}</p> <el-table-column label="操作">
</template> <template #default="scope">
</el-table-column> <el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
<el-table-column label="操作"> </el-button>
<template #default="scope"> <el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button>
<el-button circle <el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)"> </el-button>
</el-button> </template>
<el-button circle </el-table-column>
icon="el-icon-download" @click="handleDownload(scope.row)"> </el-table>
</el-button>
<el-button circle
icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
</el-button>
</template>
</el-table-column>
</el-table>
</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>

18
src/component/Ruby.vue Normal file
View 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>

Binary file not shown.

View 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');
});
});

View File

@@ -1,79 +0,0 @@
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
import {Decrypt as NcmCacheDecrypt} from "@/decrypt/ncmcache";
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
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 {DecryptResult, FileInfo} from "@/decrypt/entity";
import {SplitFilename} from "@/decrypt/utils";
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
const raw = SplitFilename(file.name)
let rt_data: DecryptResult;
switch (raw.ext) {
case "ncm":// Netease Mp3/Flac
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
break;
case "uc":// Netease Cache
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "kwm":// Kuwo Mp3/Flac
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
break
case "xm": // Xiami Wav/M4a/Mp3/Flac
case "wav":// Xiami/Raw Wav
case "mp3":// Xiami/Raw Mp3
case "flac":// Xiami/Raw Flac
case "m4a":// Xiami/Raw M4a
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
break;
case "ogg":// Raw Ogg
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm0":// QQ Music IOS Mp3
case "tm3":// QQ Music IOS Mp3
rt_data = await RawDecrypt(file.raw, raw.name, "mp3");
break;
case "qmc3"://QQ Music Android Mp3
case "qmc2"://QQ Music Android Ogg
case "qmc0"://QQ Music Android Mp3
case "qmcflac"://QQ Music Android Flac
case "qmcogg"://QQ Music Android Ogg
case "tkm"://QQ Music Accompaniment M4a
case "bkcmp3"://Moo Music Mp3
case "bkcflac"://Moo Music Flac
case "mflac"://QQ Music Desktop Flac
case "mgg": //QQ Music Desktop Ogg
case "666c6163"://QQ Music Weiyun Flac
case "6d7033"://QQ Music Weiyun Mp3
case "6f6767"://QQ Music Weiyun Ogg
case "6d3461"://QQ Music Weiyun M4a
case "776176"://QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm2":// QQ Music IOS M4a
case "tm6":// QQ Music IOS M4a
rt_data = await TmDecrypt(file.raw, raw.name);
break;
case "cache"://QQ Music Cache
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "vpr":
case "kgm":
case "kgma":
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
break
default:
throw "不支持此文件格式"
}
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
console.log(rt_data);
return rt_data;
}

View File

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

98
src/decrypt/index.ts Normal file
View File

@@ -0,0 +1,98 @@
import { Decrypt as NcmDecrypt } from '@/decrypt/ncm';
import { Decrypt as NcmCacheDecrypt } from '@/decrypt/ncmcache';
import { Decrypt as XmDecrypt } from '@/decrypt/xm';
import { Decrypt as QmcDecrypt } from '@/decrypt/qmc';
import { Decrypt as QmcCacheDecrypt } from '@/decrypt/qmccache';
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';
import { storage } from '@/utils/storage';
import InMemoryStorage from '@/utils/storage/InMemoryStorage';
export async function Decrypt(file: FileInfo, config: Record<string, any>): Promise<DecryptResult> {
// Worker thread will fallback to in-memory storage.
if (storage instanceof InMemoryStorage) {
await storage.setAll(config);
}
const raw = SplitFilename(file.name);
let rt_data: DecryptResult;
switch (raw.ext) {
case 'ncm': // Netease Mp3/Flac
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'uc': // Netease Cache
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case 'kwm': // Kuwo Mp3/Flac
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'xm': // Xiami Wav/M4a/Mp3/Flac
case 'wav': // Xiami/Raw Wav
case 'mp3': // Xiami/Raw Mp3
case 'flac': // Xiami/Raw Flac
case 'm4a': // Xiami/Raw M4a
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'ogg': // Raw Ogg
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
break;
case 'tm0': // QQ Music IOS Mp3
case 'tm3': // QQ Music IOS Mp3
rt_data = await RawDecrypt(file.raw, raw.name, 'mp3');
break;
case 'qmc3': //QQ Music Android Mp3
case 'qmc2': //QQ Music Android Ogg
case 'qmc0': //QQ Music Android Mp3
case 'qmcflac': //QQ Music Android Flac
case 'qmcogg': //QQ Music Android Ogg
case 'tkm': //QQ Music Accompaniment M4a
// Moo Music
case 'bkcmp3':
case 'bkcm4a':
case 'bkcflac':
case 'bkcwav':
case 'bkcape':
case 'bkcogg':
case 'bkcwma':
// QQ Music v2
case 'mggl': //QQ Music Mac
case 'mflac': //QQ Music New Flac
case 'mflac0': //QQ Music New Flac
case 'mgg': //QQ Music New Ogg
case 'mgg1': //QQ Music New Ogg
case '666c6163': //QQ Music Weiyun Flac
case '6d7033': //QQ Music Weiyun Mp3
case '6f6767': //QQ Music Weiyun Ogg
case '6d3461': //QQ Music Weiyun M4a
case '776176': //QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
break;
case 'tm2': // QQ Music IOS M4a
case 'tm6': // QQ Music IOS M4a
rt_data = await TmDecrypt(file.raw, raw.name);
break;
case 'cache': //QQ Music Cache
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case 'vpr':
case 'kgm':
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 '不支持此文件格式';
}
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
console.log(rt_data);
return rt_data;
}

44
src/decrypt/joox.ts Normal file
View 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,
};
}

View File

@@ -1,122 +1,144 @@
import { import {
AudioMimeType, AudioMimeType,
BytesHasPrefix, BytesHasPrefix,
GetArrayBuffer, GetArrayBuffer,
GetCoverFromFile, GetCoverFromFile,
GetMetaFromFile, GetMetaFromFile,
SniffAudioExt SniffAudioExt,
} from "@/decrypt/utils.ts"; } 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));
if (raw_ext === 'vpr') {
if (!BytesHasPrefix(oriData, VprHeader)) throw Error('Not a valid vpr file!');
} else {
if (!BytesHasPrefix(oriData, KgmHeader)) throw Error('Not a valid kgm(a) file!');
}
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer);
let headerLen = bHeaderLen.getUint32(0, true);
const oriData = new Uint8Array(await GetArrayBuffer(file)); let audioData = oriData.slice(headerLen);
if (raw_ext === "vpr") { let dataLen = audioData.length;
if (!BytesHasPrefix(oriData, VprHeader)) throw Error("Not a valid vpr file!") if (audioData.byteLength > 1 << 26) {
} else { throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁");
if (!BytesHasPrefix(oriData, KgmHeader)) throw Error("Not a valid kgm(a) file!") }
}
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer)
let headerLen = bHeaderLen.getUint32(0, true)
let audioData = oriData.slice(headerLen) let key1 = new Uint8Array(17);
let dataLen = audioData.length key1.set(oriData.slice(0x1c, 0x2c), 0);
if (audioData.byteLength > 1 << 26) { if (MaskV2.length === 0) {
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁") if (!(await LoadMaskV2())) throw Error('加载Kgm/Vpr Mask数据失败');
} }
let key1 = new Uint8Array(17) for (let i = 0; i < dataLen; i++) {
key1.set(oriData.slice(0x1c, 0x2c), 0) let med8 = key1[i % 17] ^ audioData[i];
if (MaskV2.length === 0) { med8 ^= (med8 & 0xf) << 4;
if (!await LoadMaskV2()) throw Error("加载Kgm/Vpr Mask数据失败")
}
for (let i = 0; i < dataLen; i++) { let msk8 = GetMask(i);
let med8 = key1[i % 17] ^ audioData[i] msk8 ^= (msk8 & 0xf) << 4;
med8 ^= (med8 & 0xf) << 4 audioData[i] = med8 ^ msk8;
}
if (raw_ext === 'vpr') {
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17];
}
let msk8 = GetMask(i) const ext = SniffAudioExt(audioData);
msk8 ^= (msk8 & 0xf) << 4 const mime = AudioMimeType[ext];
audioData[i] = med8 ^ msk8 let musicBlob = new Blob([audioData], { type: mime });
} const musicMeta = await metaParseBlob(musicBlob);
if (raw_ext === "vpr") { const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17] return {
} album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta),
const ext = SniffAudioExt(audioData); file: URL.createObjectURL(musicBlob),
const mime = AudioMimeType[ext]; blob: musicBlob,
let musicBlob = new Blob([audioData], {type: mime}); ext,
const musicMeta = await metaParseBlob(musicBlob); mime,
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist) title,
return { artist,
album: musicMeta.common.album, };
picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
ext,
mime,
title,
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 {
const resp = await fetch(mask_url, {method: "GET"})
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true
} catch (e) {
console.error(e)
return false
} }
}
try {
const resp = await fetch(mask_url, { method: 'GET' });
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true;
} catch (e) {
console.error(e);
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,
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68, 0x83, 0x33, 0x71, 0x51, 0x76, 0xa0, 0xcd, 0x37,
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A, 0x2f, 0x3e, 0x35, 0x8d, 0xa9, 0xbe, 0x98, 0xb7,
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B, 0xe7, 0x8c, 0x22, 0xce, 0x5a, 0x61, 0xdf, 0x68,
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7, 0x69, 0x89, 0xfe, 0xa5, 0xb6, 0xde, 0xa9, 0x77,
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48, 0xfc, 0xc8, 0xbd, 0xbd, 0xe5, 0x6d, 0x3e, 0x5a,
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B, 0x36, 0xef, 0x69, 0x4e, 0xbe, 0xe1, 0xe9, 0x66,
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84, 0x1c, 0xf3, 0xd9, 0x02, 0xb6, 0xf2, 0x12, 0x9b,
0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00, 0x44, 0xd0, 0x6f, 0xb9, 0x35, 0x89, 0xb6, 0x46,
0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B, 0x6d, 0x73, 0x82, 0x06, 0x69, 0xc1, 0xed, 0xd7,
0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB, 0x85, 0xc2, 0x30, 0xdf, 0xa2, 0x62, 0xbe, 0x79,
0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA, 0x2d, 0x62, 0x62, 0x3d, 0x0d, 0x7e, 0xbe, 0x48,
0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA, 0x89, 0x23, 0x02, 0xa0, 0xe4, 0xd5, 0x75, 0x51,
0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5, 0x32, 0x02, 0x53, 0xfd, 0x16, 0x3a, 0x21, 0x3b,
0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD, 0x16, 0x0f, 0xc3, 0xb2, 0xbb, 0xb3, 0xe2, 0xba,
0xAB, 0x12, 0xB2, 0x13, 0xE8, 0x84, 0xD7, 0xA7, 0x9F, 0x0F, 0x32, 0x4C, 0x55, 0x1D, 0x04, 0x36, 0x3a, 0x3d, 0x13, 0xec, 0xf6, 0x01, 0x45, 0x84,
0x52, 0xDC, 0x03, 0xF3, 0xF9, 0x4E, 0x42, 0xE9, 0x3D, 0x61, 0xEF, 0x7C, 0xB6, 0xB3, 0x93, 0x50, 0xa5, 0x70, 0x0f, 0x93, 0x49, 0x0c, 0x64, 0xcd,
] 0x31, 0xd5, 0xcc, 0x4c, 0x07, 0x01, 0x9e, 0x00,
0x1a, 0x23, 0x90, 0xbf, 0x88, 0x1e, 0x3b, 0xab,
0xa6, 0x3e, 0xc4, 0x73, 0x47, 0x10, 0x7e, 0x3b,
0x5e, 0xbc, 0xe3, 0x00, 0x84, 0xff, 0x09, 0xd4,
0xe0, 0x89, 0x0f, 0x5b, 0x58, 0x70, 0x4f, 0xfb,
0x65, 0xd8, 0x5c, 0x53, 0x1b, 0xd3, 0xc8, 0xc6,
0xbf, 0xef, 0x98, 0xb0, 0x50, 0x4f, 0x0f, 0xea,
0xe5, 0x83, 0x58, 0x8c, 0x28, 0x2c, 0x84, 0x67,
0xcd, 0xd0, 0x9e, 0x47, 0xdb, 0x27, 0x50, 0xca,
0xf4, 0x63, 0x63, 0xe8, 0x97, 0x7f, 0x1b, 0x4b,
0x0c, 0xc2, 0xc1, 0x21, 0x4c, 0xcc, 0x58, 0xf5,
0x94, 0x52, 0xa3, 0xf3, 0xd3, 0xe0, 0x68, 0xf4,
0x00, 0x23, 0xf3, 0x5e, 0x0a, 0x7b, 0x93, 0xdd,
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,
];

View File

@@ -1,77 +1,74 @@
import { import {
AudioMimeType, AudioMimeType,
BytesHasPrefix, BytesHasPrefix,
GetArrayBuffer, GetArrayBuffer,
GetCoverFromFile, GetCoverFromFile,
GetMetaFromFile, GetMetaFromFile,
SniffAudioExt SniffAudioExt,
} from "@/decrypt/utils.ts"; } from '@/decrypt/utils';
import {Decrypt as RawDecrypt} from "@/decrypt/raw.ts"; 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 mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime });
const ext = SniffAudioExt(audioData); const musicMeta = await metaParseBlob(musicBlob);
const mime = AudioMimeType[ext]; const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
let musicBlob = new Blob([audioData], {type: mime}); return {
album: musicMeta.common.album,
const musicMeta = await metaParseBlob(musicBlob); picture: GetCoverFromFile(musicMeta),
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist) file: URL.createObjectURL(musicBlob),
return { blob: musicBlob,
album: musicMeta.common.album, mime,
picture: GetCoverFromFile(musicMeta), title,
file: URL.createObjectURL(musicBlob), artist,
blob: musicBlob, ext,
mime, };
title,
artist,
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;
} }

View File

@@ -1,243 +1,237 @@
import { import {
AudioMimeType, AudioMimeType,
BytesHasPrefix, BytesHasPrefix,
GetArrayBuffer, GetArrayBuffer,
GetImageFromURL, GetImageFromURL,
GetMetaFromFile, IMusicMeta, GetMetaFromFile,
SniffAudioExt, IMusicMeta,
WriteMetaToFlac, SniffAudioExt,
WriteMetaToMp3 WriteMetaToFlac,
} from "@/decrypt/utils.ts"; WriteMetaToMp3,
import {parseBlob as metaParseBlob} from "music-metadata-browser"; } from '@/decrypt/utils';
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 {
const keyLen = this.view.getUint32(this.offset, true);
this.offset += 4;
const cipherText = new Uint8Array(this.raw, this.offset, keyLen).map((uint8) => uint8 ^ 0x64);
this.offset += keyLen;
const plainText = AES.decrypt(
// @ts-ignore
{ ciphertext: WordArray.create(cipherText) },
CORE_KEY,
{ mode: ModeECB, padding: PKCS7 },
);
const result = new Uint8Array(plainText.sigBytes);
const words = plainText.words;
const sigBytes = plainText.sigBytes;
for (let i = 0; i < sigBytes; i++) {
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
} }
_getKeyData(): Uint8Array { return result.slice(17);
const keyLen = this.view.getUint32(this.offset, true); }
this.offset += 4;
const cipherText = new Uint8Array(this.raw, this.offset, keyLen)
.map(uint8 => uint8 ^ 0x64);
this.offset += keyLen;
const plainText = AES.decrypt( _getKeyBox(): Uint8Array {
// @ts-ignore const keyData = this._getKeyData();
{ciphertext: WordArray.create(cipherText)}, const box = new Uint8Array(Array(256).keys());
CORE_KEY,
{mode: ModeECB, padding: PKCS7}
);
const result = new Uint8Array(plainText.sigBytes); const keyDataLen = keyData.length;
const words = plainText.words; let j = 0;
const sigBytes = plainText.sigBytes;
for (let i = 0; i < sigBytes; i++) {
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
}
return result.slice(17) for (let i = 0; i < 256; i++) {
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
[box[i], box[j]] = [box[j], box[i]];
} }
_getKeyBox(): Uint8Array { return box.map((_, i, arr) => {
const keyData = this._getKeyData() i = (i + 1) & 0xff;
const box = new Uint8Array(Array(256).keys()); const si = arr[i];
const sj = arr[(i + si) & 0xff];
return arr[(si + sj) & 0xff];
});
}
const keyDataLen = keyData.length; _getMetaData(): NcmMusicMeta {
const metaDataLen = this.view.getUint32(this.offset, true);
this.offset += 4;
if (metaDataLen === 0) return {};
let j = 0; const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen).map((data) => data ^ 0x63);
this.offset += metaDataLen;
for (let i = 0; i < 256; i++) { WordArray.create();
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff; const plainText = AES.decrypt(
[box[i], box[j]] = [box[j], box[i]]; // @ts-ignore
} {
ciphertext: Base64.parse(
// @ts-ignore
WordArray.create(cipherText.slice(22)).toString(EncUTF8),
),
},
META_KEY,
{ mode: ModeECB, padding: PKCS7 },
).toString(EncUTF8);
return box.map((_, i, arr) => { const labelIndex = plainText.indexOf(':');
i = (i + 1) & 0xff; let result: NcmMusicMeta;
const si = arr[i]; if (plainText.slice(0, labelIndex) === 'dj') {
const sj = arr[(i + si) & 0xff]; const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
return arr[(si + sj) & 0xff]; result = tmp.mainMusic;
}); } else {
result = JSON.parse(plainText.slice(labelIndex + 1));
}
if (!!result.albumPic) {
result.albumPic = result.albumPic.replace('http://', 'https://') + '?param=500y500';
}
return result;
}
_getAudio(keyBox: Uint8Array): Uint8Array {
this.offset += this.view.getUint32(this.offset + 5, true) + 13;
const audioData = new Uint8Array(this.raw, this.offset);
let lenAudioData = audioData.length;
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff];
return audioData;
}
async _buildMeta() {
if (!this.oriMeta) throw Error('invalid sequence');
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName);
// build artists
let artists: string[] = [];
if (!!this.oriMeta.artist) {
this.oriMeta.artist.forEach((arr) => artists.push(<string>arr[0]));
} }
_getMetaData(): NcmMusicMeta { if (artists.length === 0 && !!info.artist) {
const metaDataLen = this.view.getUint32(this.offset, true); artists = info.artist
this.offset += 4; .split(',')
if (metaDataLen === 0) return {}; .map((val) => val.trim())
.filter((val) => val != '');
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen)
.map(data => data ^ 0x63);
this.offset += metaDataLen;
WordArray.create()
const plainText = AES.decrypt(
// @ts-ignore
{
ciphertext: Base64.parse(
// @ts-ignore
WordArray.create(cipherText.slice(22)).toString(EncUTF8)
)
},
META_KEY,
{mode: ModeECB, padding: PKCS7}
).toString(EncUTF8);
const labelIndex = plainText.indexOf(":");
let result: NcmMusicMeta;
if (plainText.slice(0, labelIndex) === "dj") {
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
result = tmp.mainMusic;
} else {
result = JSON.parse(plainText.slice(labelIndex + 1));
}
if (!!result.albumPic) {
result.albumPic = result.albumPic.replace("http://", "https://") + "?param=500y500"
}
return result
} }
_getAudio(keyBox: Uint8Array): Uint8Array { if (this.oriMeta.albumPic)
this.offset += this.view.getUint32(this.offset + 5, true) + 13 try {
const audioData = new Uint8Array(this.raw, this.offset) this.image = await GetImageFromURL(this.oriMeta.albumPic);
let lenAudioData = audioData.length while (this.image && this.image.buffer.byteLength >= 1 << 24) {
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff] let img = await jimp.read(Buffer.from(this.image.buffer));
return audioData await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO);
this.image.buffer = await img.getBufferAsync('image/jpeg');
}
} catch (e) {
console.log('get cover image failed', e);
}
this.newMeta = { title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer };
}
async _writeMeta() {
if (!this.audio || !this.newMeta) throw Error('invalid sequence');
if (!this.blob) this.blob = new Blob([this.audio], { type: this.mime });
const ori = await metaParseBlob(this.blob);
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title;
if (shouldWrite || this.newMeta.picture) {
if (this.format === 'mp3') {
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori);
} else if (this.format === 'flac') {
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori);
} else {
console.info(`writing meta for ${this.format} is not being supported for now`);
return;
}
this.blob = new Blob([this.audio], { type: this.mime });
} }
}
async _buildMeta() { gatherResult(): DecryptResult {
if (!this.oriMeta) throw Error("invalid sequence") if (!this.newMeta || !this.blob) throw Error('bad sequence');
return {
title: this.newMeta.title,
artist: this.newMeta.artists?.join('; '),
ext: this.format,
album: this.newMeta.album,
picture: this.image?.url,
file: URL.createObjectURL(this.blob),
blob: this.blob,
mime: this.mime,
};
}
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName) async decrypt() {
const keyBox = this._getKeyBox();
// build artists this.oriMeta = this._getMetaData();
let artists: string[] = []; this.audio = this._getAudio(keyBox);
if (!!this.oriMeta.artist) { this.format = this.oriMeta.format || SniffAudioExt(this.audio);
this.oriMeta.artist.forEach(arr => artists.push(<string>arr[0])); this.mime = AudioMimeType[this.format];
} await this._buildMeta();
try {
if (artists.length === 0 && !!info.artist) { await this._writeMeta();
artists = info.artist.split(',') } catch (e) {
.map(val => val.trim()).filter(val => val != ""); console.warn('write meta data failed', e);
}
if (this.oriMeta.albumPic) try {
this.image = await GetImageFromURL(this.oriMeta.albumPic)
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
let img = await jimp.read(Buffer.from(this.image.buffer))
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO)
this.image.buffer = await img.getBufferAsync("image/jpeg")
}
} catch (e) {
console.log("get cover image failed", e)
}
this.newMeta = {title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer}
} }
return this.gatherResult();
async _writeMeta() { }
if (!this.audio || !this.newMeta) throw Error("invalid sequence")
if (!this.blob) this.blob = new Blob([this.audio], {type: this.mime})
const ori = await metaParseBlob(this.blob);
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title
if (shouldWrite || this.newMeta.picture) {
if (this.format === "mp3") {
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori)
} else if (this.format === "flac") {
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori)
} else {
console.info(`writing meta for ${this.format} is not being supported for now`)
return
}
this.blob = new Blob([this.audio], {type: this.mime})
}
}
gatherResult(): DecryptResult {
if (!this.newMeta) throw Error("bad sequence")
return {
title: this.newMeta.title,
artist: this.newMeta.artists?.join("; "),
ext: this.format,
album: this.newMeta.album,
picture: this.image?.url,
file: URL.createObjectURL(this.blob),
blob: this.blob as Blob,
mime: this.mime
}
}
async decrypt() {
const keyBox = this._getKeyBox()
this.oriMeta = this._getMetaData()
this.audio = this._getAudio(keyBox)
this.format = this.oriMeta.format || SniffAudioExt(this.audio)
this.mime = AudioMimeType[this.format]
await this._buildMeta()
try {
await this._writeMeta()
} catch (e) {
console.warn("write meta data failed", e)
}
return this.gatherResult()
}
} }

View File

@@ -1,29 +1,28 @@
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts"; 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,
artist, artist,
ext, ext,
album: tag.common.album, album: tag.common.album,
picture: GetCoverFromFile(tag), picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file), file: URL.createObjectURL(file),
blob: file, blob: file,
mime: AudioMimeType[ext] mime: AudioMimeType[ext],
} };
} }

29
src/decrypt/qmc.test.ts Normal file
View File

@@ -0,0 +1,29 @@
import fs from 'fs';
import { QmcDecoder } from '@/decrypt/qmc';
import { BytesEqual } from '@/decrypt/utils';
function loadTestDataDecoder(name: string): {
cipherText: Uint8Array;
clearText: Uint8Array;
} {
const cipherBody = fs.readFileSync(`./testdata/${name}_raw.bin`);
const cipherSuffix = fs.readFileSync(`./testdata/${name}_suffix.bin`);
const cipherText = new Uint8Array(cipherBody.length + cipherSuffix.length);
cipherText.set(cipherBody);
cipherText.set(cipherSuffix, cipherBody.length);
return {
cipherText,
clearText: fs.readFileSync(`testdata/${name}_target.bin`),
};
}
test('qmc: real file', async () => {
const cases = ['mflac0_rc4', 'mflac_rc4', 'mflac_map', 'mgg_map', 'qmc0_static'];
for (const name of cases) {
const { clearText, cipherText } = loadTestDataDecoder(name);
const c = new QmcDecoder(cipherText);
const buf = c.decrypt();
expect(BytesEqual(buf, clearText)).toBeTruthy();
}
});

View File

@@ -1,141 +1,171 @@
import {QmcMask, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask"; import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
import {toByteArray as Base64Decode} from 'base64-js' import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
SniffAudioExt, WriteMetaToFlac, WriteMetaToMp3
} from "@/decrypt/utils.ts";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import { DecryptResult } from '@/decrypt/entity';
import iconv from "iconv-lite"; import { QmcDeriveKey } from '@/decrypt/qmc_key';
import {DecryptResult} from "@/decrypt/entity"; import { DecryptQMCWasm } from '@/decrypt/qmc_wasm';
import {queryAlbumCover, queryKeyInfo, reportKeyUsage} from "@/utils/api"; import { extractQQMusicMeta } from '@/utils/qm_meta';
interface Handler { interface Handler {
ext: string ext: string;
detect: boolean version: number;
handler(data?: Uint8Array): QmcMask | undefined
} }
export const HandlerMap: { [key: string]: Handler } = { export const HandlerMap: { [key: string]: Handler } = {
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true}, mgg: { ext: 'ogg', version: 2 },
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true}, mgg1: { ext: 'ogg', version: 2 },
"mgg.cache": {handler: QmcMaskDetectMgg, ext: "ogg", detect: false}, mflac: { ext: 'flac', version: 2 },
"mflac.cache": {handler: QmcMaskDetectMflac, ext: "flac", detect: false}, mflac0: { ext: 'flac', version: 2 },
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false}, // qmcflac / qmcogg:
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false}, // 有可能是 v2 加密但混用同一个后缀名。
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false}, qmcflac: { ext: 'flac', version: 2 },
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false}, qmcogg: { ext: 'ogg', version: 2 },
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false}, qmc0: { ext: 'mp3', version: 1 },
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false}, qmc2: { ext: 'ogg', version: 1 },
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false}, qmc3: { ext: 'mp3', version: 1 },
"6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false}, bkcmp3: { ext: 'mp3', version: 1 },
"6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false}, bkcflac: { ext: 'flac', version: 1 },
"6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false}, tkm: { ext: 'm4a', version: 1 },
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false} '666c6163': { ext: 'flac', version: 1 },
'6d7033': { ext: 'mp3', version: 1 },
'6f6767': { ext: 'ogg', version: 1 },
'6d3461': { ext: 'm4a', 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;
const fileData = new Uint8Array(await GetArrayBuffer(file)); const fileBuffer = await GetArrayBuffer(file);
let audioData, seed, keyData; let musicDecoded: Uint8Array | undefined;
if (handler.detect) { let musicID: number | string | undefined;
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
const keyPos = fileData.length - 4 - keyLen; if (version === 2 && globalThis.WebAssembly) {
audioData = fileData.slice(0, keyPos); console.log('qmc: using wasm decoder');
seed = handler.handler(audioData);
keyData = fileData.slice(keyPos, keyPos + keyLen); const v2Decrypted = await DecryptQMCWasm(fileBuffer);
if (!seed) seed = await queryKey(keyData, raw_filename, raw_ext); // 若 v2 检测失败,降级到 v1 再尝试一次
if (!seed) throw raw_ext + "格式仅提供实验性支持"; if (v2Decrypted.success) {
musicDecoded = v2Decrypted.data;
musicID = v2Decrypted.songId;
} else { } else {
audioData = fileData; console.warn('qmc2-wasm failed with error %s', v2Decrypted.error || '(no error)');
seed = handler.handler(audioData) as QmcMask;
if (!seed) throw raw_ext + "格式仅提供实验性支持";
} }
let musicDecoded = seed.Decrypt(audioData); }
const ext = SniffAudioExt(musicDecoded, handler.ext); if (!musicDecoded) {
const mime = AudioMimeType[ext]; // may throw error
console.log('qmc: using js decoder');
const d = new QmcDecoder(new Uint8Array(fileBuffer));
musicDecoded = d.decrypt();
musicID = d.songID;
}
let musicBlob = new Blob([musicDecoded], {type: mime}); const ext = SniffAudioExt(musicDecoded, handler.ext);
const mime = AudioMimeType[ext];
const musicMeta = await metaParseBlob(musicBlob); const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
for (let metaIdx in musicMeta.native) { new Blob([musicDecoded], { type: mime }),
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue raw_filename,
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) { ext,
console.warn("try using gbk encoding to decode meta") musicID,
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) return {
if (keyData) reportKeyUsage(keyData, seed.getMatrix128(), title: title,
raw_filename, raw_ext, info.title, info.artist, musicMeta.common.album).then().catch(); artist: artist,
ext: ext,
let imgUrl = GetCoverFromFile(musicMeta); album: album,
if (!imgUrl) { picture: imgUrl,
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album); file: URL.createObjectURL(blob),
if (imgUrl) { blob: blob,
const imageInfo = await GetImageFromURL(imgUrl); mime: mime,
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 {
title: info.title,
artist: info.artist,
ext: ext,
album: musicMeta.common.album,
picture: imgUrl,
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime: mime
}
} }
export class QmcDecoder {
private static readonly BYTE_COMMA = ','.charCodeAt(0);
private readonly file: Uint8Array;
private readonly size: number;
private decoded: boolean = false;
private audioSize?: number;
private cipher?: QmcStreamCipher;
async function queryKey(keyData: Uint8Array, filename: string, format: string): Promise<QmcMask | undefined> { public constructor(file: Uint8Array) {
try { this.file = file;
const data = await queryKeyInfo(keyData, filename, format) this.size = file.length;
return new QmcMask(Base64Decode(data.Matrix44)); this.searchKey();
} catch (e) { }
console.warn(e);
}
}
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> { private _songID?: number;
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
try { public get songID() {
const data = await queryAlbumCover(title, artist, album) return this._songID;
return `${song_query_url}/${data.Type}/${data.Id}` }
} catch (e) {
console.warn(e); public decrypt(): Uint8Array {
if (!this.cipher) {
throw new Error('no cipher found');
} }
return "" if (!this.audioSize || this.audioSize <= 0) {
throw new Error('invalid audio size');
}
const audioBuf = this.file.subarray(0, this.audioSize);
if (!this.decoded) {
this.cipher.decrypt(audioBuf, 0);
this.decoded = true;
}
return audioBuf;
}
private searchKey() {
const last4Byte = this.file.slice(-4);
const textEnc = new TextDecoder();
if (textEnc.decode(last4Byte) === 'QTag') {
const sizeBuf = this.file.slice(-8, -4);
const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset);
const keySize = sizeView.getUint32(0, false);
this.audioSize = this.size - keySize - 8;
const rawKey = this.file.subarray(this.audioSize, this.size - 8);
const keyEnd = rawKey.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
if (keyEnd < 0) {
throw new Error('invalid key: search raw key failed');
}
this.setCipher(rawKey.subarray(0, keyEnd));
const idBuf = rawKey.subarray(keyEnd + 1);
const idEnd = idBuf.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
if (keyEnd < 0) {
throw new Error('invalid key: search song id failed');
}
this._songID = parseInt(textEnc.decode(idBuf.subarray(0, idEnd)), 10);
} else {
const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset);
const keySize = sizeView.getUint32(0, true);
if (keySize < 0x300) {
this.audioSize = this.size - keySize - 4;
const rawKey = this.file.subarray(this.audioSize, this.size - 4);
this.setCipher(rawKey);
} else {
this.audioSize = this.size;
this.cipher = new QmcStaticCipher();
}
}
}
private setCipher(keyRaw: Uint8Array) {
const keyDec = QmcDeriveKey(keyRaw);
if (keyDec.length > 300) {
this.cipher = new QmcRC4Cipher(keyDec);
} else {
this.cipher = new QmcMapCipher(keyDec);
}
}
} }

View File

@@ -1,206 +0,0 @@
import {BytesHasPrefix, FLAC_HEADER, OGG_HEADER} from "@/decrypt/utils.ts";
const QMOggPublicHeader1 = [
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff,
0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72,
0x62, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xee, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x4f, 0x67, 0x67, 0x53, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0xff];
const QMOggPublicHeader2 = [
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x2c, 0x00, 0x00, 0x00, 0x58, 0x69, 0x70, 0x68, 0x2e,
0x4f, 0x72, 0x67, 0x20, 0x6c, 0x69, 0x62, 0x56, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x20, 0x49, 0x20,
0x32, 0x30, 0x31, 0x35, 0x30, 0x31, 0x30, 0x35, 0x20, 0x28, 0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84,
0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84, 0x29, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x54,
0x49, 0x54, 0x4c, 0x45, 0x3d];
const QMOggPublicConf1 = [
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0,
0, 0, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 6, 3, 3, 3, 3, 6, 6, 6, 6,
3, 3, 3, 3, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9,
0, 0, 0, 0];
const QMOggPublicConf2 = [
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 0, 1, 3, 3, 0, 1, 3, 3, 3,
3, 3, 3, 3, 3];
const QMCDefaultMaskMatrix = [
0xde, 0x51, 0xfa, 0xc3, 0x4a, 0xd6, 0xca, 0x90,
0x7e, 0x67, 0x5e, 0xf7, 0xd5, 0x52, 0x84, 0xd8,
0x47, 0x95, 0xbb, 0xa1, 0xaa, 0xc6, 0x66, 0x23,
0x92, 0x62, 0xf3, 0x74, 0xa1, 0x9f, 0xf4, 0xa0,
0x1d, 0x3f, 0x5b, 0xf0, 0x13, 0x0e, 0x09, 0x3d,
0xf9, 0xbc, 0x00, 0x11];
const AllMapping: number[][] = [];
const Mask128to44: number[] = [];
(function () {
for (let i = 0; i < 128; i++) {
let realIdx = (i * i + 27) % 256
if (realIdx in AllMapping) {
AllMapping[realIdx].push(i)
} else {
AllMapping[realIdx] = [i]
}
}
let idx44 = 0
AllMapping.forEach(all128 => {
all128.forEach(_i128 => {
Mask128to44[_i128] = idx44
})
idx44++
})
})();
export class QmcMask {
private readonly Matrix128: number[];
constructor(matrix: number[] | Uint8Array) {
if (matrix instanceof Uint8Array) matrix = Array.from(matrix)
if (matrix.length === 44) {
this.Matrix128 = this._generate128(matrix)
} else if (matrix.length === 128) {
this.Matrix128 = matrix
} else {
throw Error("invalid mask length")
}
}
getMatrix128() {
return this.Matrix128
}
getMatrix44(): number[] {
const matrix44: number[] = []
let idxI44 = 0
AllMapping.forEach(it256 => {
let it256Len = it256.length
for (let i = 1; i < it256Len; i++) {
if (this.Matrix128[it256[0]] !== this.Matrix128[it256[i]]) {
throw "decode mask-128 to mask-44 failed"
}
}
matrix44[idxI44] = this.Matrix128[it256[0]]
idxI44++
})
return matrix44
}
Decrypt(data: Uint8Array) {
if (!this.Matrix128) throw Error("bad call sequence")
let dst = data.slice(0);
let index = -1;
let maskIdx = -1;
for (let cur = 0; cur < data.length; cur++) {
index++;
maskIdx++;
if (index === 0x8000 || (index > 0x8000 && (index + 1) % 0x8000 === 0)) {
index++;
maskIdx++;
}
if (maskIdx >= 128) maskIdx -= 128;
dst[cur] ^= this.Matrix128[maskIdx];
}
return dst;
}
private _generate128(matrix44: number[]): number[] {
const matrix128: number[] = []
let idx44 = 0
AllMapping.forEach(it256 => {
it256.forEach(m => {
matrix128[m] = matrix44[idx44]
})
idx44++
})
return matrix128
}
}
export function QmcMaskGetDefault() {
return new QmcMask(QMCDefaultMaskMatrix)
}
export function QmcMaskDetectMflac(data: Uint8Array) {
let search_len = Math.min(0x8000, data.length), mask;
for (let block_idx = 0; block_idx < search_len; block_idx += 128) {
try {
mask = new QmcMask(data.slice(block_idx, block_idx + 128));
if (BytesHasPrefix(mask.Decrypt(data.slice(0, FLAC_HEADER.length)), FLAC_HEADER)) {
break;
}
} catch (e) {
}
}
return mask;
}
export function QmcMaskDetectMgg(data: Uint8Array) {
if (data.length < 0x100) return
let matrixConfidence: { [key: number]: { [key: number]: number } } = {};
for (let i = 0; i < 44; i++) matrixConfidence[i] = {};
const page2 = data[0x54] ^ data[0xC] ^ QMOggPublicHeader1[0xC];
const spHeader = QmcGenerateOggHeader(page2)
const spConf = QmcGenerateOggConf(page2)
for (let idx128 = 0; idx128 < spHeader.length; idx128++) {
if (spConf[idx128] === 0) continue;
let idx44 = Mask128to44[idx128 % 128];
let _m = data[idx128] ^ spHeader[idx128]
let confidence = spConf[idx128];
if (_m in matrixConfidence[idx44]) {
matrixConfidence[idx44][_m] += confidence
} else {
matrixConfidence[idx44][_m] = confidence
}
}
let matrix = [];
try {
for (let i = 0; i < 44; i++)
matrix[i] = calcMaskFromConfidence(matrixConfidence[i]);
} catch (e) {
return;
}
const mask = new QmcMask(matrix);
if (!BytesHasPrefix(mask.Decrypt(data.slice(0, OGG_HEADER.length)), OGG_HEADER)) {
return;
}
return mask;
}
function calcMaskFromConfidence(confidence: { [key: number]: number }) {
const count = Object.keys(confidence).length
if (count === 0) throw "can not match at least one key";
if (count > 1) console.warn("There are 2 potential value for the mask!")
let result = ""
let conf = 0
for (let idx in confidence) {
if (confidence[idx] > conf) {
result = idx;
conf = confidence[idx];
}
}
return Number(result)
}
function QmcGenerateOggHeader(page2: number) {
let spec = [page2, 0xFF]
for (let i = 2; i < page2; i++) spec.push(0xFF)
spec.push(0xFF)
return QMOggPublicHeader1.concat(spec, QMOggPublicHeader2)
}
function QmcGenerateOggConf(page2: number) {
let specConf = [6, 0]
for (let i = 2; i < page2; i++) specConf.push(4)
specConf.push(0)
return QMOggPublicConf1.concat(specConf, QMOggPublicConf2)
}

View File

@@ -0,0 +1,117 @@
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher } from '@/decrypt/qmc_cipher';
import fs from 'fs';
test('static cipher [0x7ff8,0x8000) ', () => {
//prettier-ignore
const expected = new Uint8Array([
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
])
const c = new QmcStaticCipher();
const buf = new Uint8Array(16);
c.decrypt(buf, 0x7ff8);
expect(buf).toStrictEqual(expected);
});
test('static cipher [0,0x10) ', () => {
//prettier-ignore
const expected = new Uint8Array([
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
])
const c = new QmcStaticCipher();
const buf = new Uint8Array(16);
c.decrypt(buf, 0);
expect(buf).toStrictEqual(expected);
});
test('map cipher: get mask', () => {
//prettier-ignore
const expected = new Uint8Array([
0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB,
0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79,
])
const key = new Uint8Array(256);
for (let i = 0; i < 256; i++) key[i] = i;
const buf = new Uint8Array(16);
const c = new QmcMapCipher(key);
c.decrypt(buf, 0);
expect(buf).toStrictEqual(expected);
});
function loadTestDataCipher(name: string): {
key: Uint8Array;
cipherText: Uint8Array;
clearText: Uint8Array;
} {
return {
key: fs.readFileSync(`testdata/${name}_key.bin`),
cipherText: fs.readFileSync(`testdata/${name}_raw.bin`),
clearText: fs.readFileSync(`testdata/${name}_target.bin`),
};
}
test('map cipher: real file', async () => {
const cases = ['mflac_map', 'mgg_map'];
for (const name of cases) {
const { key, clearText, cipherText } = loadTestDataCipher(name);
const c = new QmcMapCipher(key);
c.decrypt(cipherText, 0);
expect(cipherText).toStrictEqual(clearText);
}
});
test('rc4 cipher: real file', async () => {
const cases = ['mflac0_rc4', 'mflac_rc4'];
for (const name of cases) {
const { key, clearText, cipherText } = loadTestDataCipher(name);
const c = new QmcRC4Cipher(key);
c.decrypt(cipherText, 0);
expect(cipherText).toStrictEqual(clearText);
}
});
test('rc4 cipher: first segment', async () => {
const cases = ['mflac0_rc4', 'mflac_rc4'];
for (const name of cases) {
const { key, clearText, cipherText } = loadTestDataCipher(name);
const c = new QmcRC4Cipher(key);
const buf = cipherText.slice(0, 128);
c.decrypt(buf, 0);
expect(buf).toStrictEqual(clearText.slice(0, 128));
}
});
test('rc4 cipher: align block (128~5120)', async () => {
const cases = ['mflac0_rc4', 'mflac_rc4'];
for (const name of cases) {
const { key, clearText, cipherText } = loadTestDataCipher(name);
const c = new QmcRC4Cipher(key);
const buf = cipherText.slice(128, 5120);
c.decrypt(buf, 128);
expect(buf).toStrictEqual(clearText.slice(128, 5120));
}
});
test('rc4 cipher: simple block (5120~10240)', async () => {
const cases = ['mflac0_rc4', 'mflac_rc4'];
for (const name of cases) {
const { key, clearText, cipherText } = loadTestDataCipher(name);
const c = new QmcRC4Cipher(key);
const buf = cipherText.slice(5120, 10240);
c.decrypt(buf, 5120);
expect(buf).toStrictEqual(clearText.slice(5120, 10240));
}
});

199
src/decrypt/qmc_cipher.ts Normal file
View File

@@ -0,0 +1,199 @@
export interface QmcStreamCipher {
decrypt(buf: Uint8Array, offset: number): void;
}
export class QmcStaticCipher implements QmcStreamCipher {
//prettier-ignore
private static readonly staticCipherBox: Uint8Array = new Uint8Array([
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10
0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20
0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30
0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40
0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50
0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60
0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70
0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80
0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90
0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0
0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0
0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0
0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0
0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0
0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0
0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8
])
public getMask(offset: number) {
if (offset > 0x7fff) offset %= 0x7fff;
return QmcStaticCipher.staticCipherBox[(offset * offset + 27) & 0xff];
}
public decrypt(buf: Uint8Array, offset: number) {
for (let i = 0; i < buf.length; i++) {
buf[i] ^= this.getMask(offset + i);
}
}
}
export class QmcMapCipher implements QmcStreamCipher {
key: Uint8Array;
n: number;
constructor(key: Uint8Array) {
if (key.length == 0) throw Error('qmc/cipher_map: invalid key size');
this.key = key;
this.n = key.length;
}
private static rotate(value: number, bits: number) {
let rotate = (bits + 4) % 8;
let left = value << rotate;
let right = value >> rotate;
return (left | right) & 0xff;
}
decrypt(buf: Uint8Array, offset: number): void {
for (let i = 0; i < buf.length; i++) {
buf[i] ^= this.getMask(offset + i);
}
}
private getMask(offset: number) {
if (offset > 0x7fff) offset %= 0x7fff;
const idx = (offset * offset + 71214) % this.n;
return QmcMapCipher.rotate(this.key[idx], idx & 0x7);
}
}
export class QmcRC4Cipher implements QmcStreamCipher {
private static readonly FIRST_SEGMENT_SIZE = 0x80;
private static readonly SEGMENT_SIZE = 5120;
S: Uint8Array;
N: number;
key: Uint8Array;
hash: number;
constructor(key: Uint8Array) {
if (key.length == 0) {
throw Error('invalid key size');
}
this.key = key;
this.N = key.length;
// init seed box
this.S = new Uint8Array(this.N);
for (let i = 0; i < this.N; ++i) {
this.S[i] = i & 0xff;
}
let j = 0;
for (let i = 0; i < this.N; ++i) {
j = (this.S[i] + j + this.key[i % this.N]) % this.N;
[this.S[i], this.S[j]] = [this.S[j], this.S[i]];
}
// init hash base
this.hash = 1;
for (let i = 0; i < this.N; i++) {
let value = this.key[i];
// ignore if key char is '\x00'
if (!value) continue;
const next_hash = (this.hash * value) >>> 0;
if (next_hash == 0 || next_hash <= this.hash) break;
this.hash = next_hash;
}
}
decrypt(buf: Uint8Array, offset: number): void {
let toProcess = buf.length;
let processed = 0;
const postProcess = (len: number): boolean => {
toProcess -= len;
processed += len;
offset += len;
return toProcess == 0;
};
// Initial segment
if (offset < QmcRC4Cipher.FIRST_SEGMENT_SIZE) {
const len_segment = Math.min(buf.length, QmcRC4Cipher.FIRST_SEGMENT_SIZE - offset);
this.encFirstSegment(buf.subarray(0, len_segment), offset);
if (postProcess(len_segment)) return;
}
// align segment
if (offset % QmcRC4Cipher.SEGMENT_SIZE != 0) {
const len_segment = Math.min(QmcRC4Cipher.SEGMENT_SIZE - (offset % QmcRC4Cipher.SEGMENT_SIZE), toProcess);
this.encASegment(buf.subarray(processed, processed + len_segment), offset);
if (postProcess(len_segment)) return;
}
// Batch process segments
while (toProcess > QmcRC4Cipher.SEGMENT_SIZE) {
this.encASegment(buf.subarray(processed, processed + QmcRC4Cipher.SEGMENT_SIZE), offset);
postProcess(QmcRC4Cipher.SEGMENT_SIZE);
}
// Last segment (incomplete segment)
if (toProcess > 0) {
this.encASegment(buf.subarray(processed), offset);
}
}
private encFirstSegment(buf: Uint8Array, offset: number) {
for (let i = 0; i < buf.length; i++) {
buf[i] ^= this.key[this.getSegmentKey(offset + i)];
}
}
private encASegment(buf: Uint8Array, offset: number) {
// Initialise a new seed box
const S = this.S.slice(0);
// Calculate the number of bytes to skip.
// The initial "key" derived from segment id, plus the current offset.
const skipLen =
(offset % QmcRC4Cipher.SEGMENT_SIZE) + this.getSegmentKey(Math.floor(offset / QmcRC4Cipher.SEGMENT_SIZE));
// decrypt the block
let j = 0;
let k = 0;
for (let i = -skipLen; i < buf.length; i++) {
j = (j + 1) % this.N;
k = (S[j] + k) % this.N;
[S[k], S[j]] = [S[j], S[k]];
if (i >= 0) {
buf[i] ^= S[(S[j] + S[k]) % this.N];
}
}
}
private getSegmentKey(id: number): number {
const seed = this.key[id % this.N];
const idx = Math.floor((this.hash / ((id + 1) * seed)) * 100.0);
return idx % this.N;
}
}

View File

@@ -0,0 +1,26 @@
import { QmcDeriveKey, simpleMakeKey } from '@/decrypt/qmc_key';
import fs from 'fs';
test('key dec: make simple key', () => {
expect(simpleMakeKey(106, 8)).toStrictEqual([0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b]);
});
function loadTestDataKeyDecrypt(name: string): {
cipherText: Uint8Array;
clearText: Uint8Array;
} {
return {
cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`),
clearText: fs.readFileSync(`testdata/${name}_key.bin`),
};
}
test('key dec: real file', async () => {
const cases = ['mflac_map', 'mgg_map', 'mflac0_rc4', 'mflac_rc4'];
for (const name of cases) {
const { clearText, cipherText } = loadTestDataKeyDecrypt(name);
const buf = QmcDeriveKey(cipherText);
expect(buf).toStrictEqual(clearText);
}
});

103
src/decrypt/qmc_key.ts Normal file
View File

@@ -0,0 +1,103 @@
import { TeaCipher } from '@/utils/tea';
const SALT_LEN = 2;
const ZERO_LEN = 7;
export function QmcDeriveKey(raw: Uint8Array): Uint8Array {
const textDec = new TextDecoder();
const rawDec = Buffer.from(textDec.decode(raw), 'base64');
let n = rawDec.length;
if (n < 16) {
throw Error('key length is too short');
}
const simpleKey = simpleMakeKey(106, 8);
let teaKey = new Uint8Array(16);
for (let i = 0; i < 8; i++) {
teaKey[i << 1] = simpleKey[i];
teaKey[(i << 1) + 1] = rawDec[i];
}
const sub = decryptTencentTea(rawDec.subarray(8), teaKey);
rawDec.set(sub, 8);
return rawDec.subarray(0, 8 + sub.length);
}
// simpleMakeKey exported only for unit test
export function simpleMakeKey(salt: number, length: number): number[] {
const keyBuf: number[] = [];
for (let i = 0; i < length; i++) {
const tmp = Math.tan(salt + i * 0.1);
keyBuf[i] = 0xff & (Math.abs(tmp) * 100.0);
}
return keyBuf;
}
function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array {
if (inBuf.length % 8 != 0) {
throw Error('inBuf size not a multiple of the block size');
}
if (inBuf.length < 16) {
throw Error('inBuf size too small');
}
const blk = new TeaCipher(key, 32);
const tmpBuf = new Uint8Array(8);
const tmpView = new DataView(tmpBuf.buffer);
blk.decrypt(tmpView, new DataView(inBuf.buffer, inBuf.byteOffset, 8));
const nPadLen = tmpBuf[0] & 0x7; //只要最低三位
/*密文格式: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 outBuf = new Uint8Array(outLen);
let ivPrev = new Uint8Array(8);
let ivCur = inBuf.slice(0, 8); // init iv
let inBufPos = 8;
// 跳过 Padding Len 和 Padding
let tmpIdx = 1 + nPadLen;
// CBC IV 处理
const cryptBlock = () => {
ivPrev = ivCur;
ivCur = inBuf.slice(inBufPos, inBufPos + 8);
for (let j = 0; j < 8; j++) {
tmpBuf[j] ^= ivCur[j];
}
blk.decrypt(tmpView, tmpView);
inBufPos += 8;
tmpIdx = 0;
};
// 跳过 Salt
for (let i = 1; i <= SALT_LEN; ) {
if (tmpIdx < 8) {
tmpIdx++;
i++;
} else {
cryptBlock();
}
}
// 还原明文
let outBufPos = 0;
while (outBufPos < outLen) {
if (tmpIdx < 8) {
outBuf[outBufPos] = tmpBuf[tmpIdx] ^ ivPrev[tmpIdx];
outBufPos++;
tmpIdx++;
} else {
cryptBlock();
}
}
// 校验Zero
for (let i = 1; i <= ZERO_LEN; i++) {
if (tmpBuf[tmpIdx] != ivPrev[tmpIdx]) {
throw Error('zero check failed');
}
}
return outBuf;
}

111
src/decrypt/qmc_wasm.ts Normal file
View File

@@ -0,0 +1,111 @@
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
import { MergeUint8Array } from '@/utils/MergeUint8Array';
import { QMCCrypto } from '@jixun/qmc2-crypto/QMCCrypto';
// 检测文件末端使用的缓冲区大小
const DETECTION_SIZE = 40;
// 每次处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
export interface QMC2DecryptionResult {
success: boolean;
data: Uint8Array;
songId: string | number;
error: string;
}
/**
* 解密一个 QMC2 加密的文件。
*
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
*/
export async function DecryptQMCWasm(mggBlob: ArrayBuffer): Promise<QMC2DecryptionResult> {
const result: QMC2DecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
// 初始化模组
let QMCCrypto: QMCCrypto;
try {
QMCCrypto = await QMCCryptoModule();
} catch (err: any) {
result.error = err?.message || 'wasm 加载失败';
return result;
}
// 申请内存块,并文件末端数据到 WASM 的内存堆
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length);
QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf);
// 检测结果内存块
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
// 进行检测
const detectOK = QMCCrypto.detectKeyEndPosition(pDetectionResult, pDetectionBuf, detectionBuf.length);
// 提取结构体内容:
// (pos: i32; len: i32; error: char[??])
const position = QMCCrypto.getValue(pDetectionResult, 'i32');
const len = QMCCrypto.getValue(pDetectionResult + 4, 'i32');
result.success = detectOK;
result.error = QMCCrypto.UTF8ToString(
pDetectionResult + QMCCrypto.offsetof_error_msg(),
QMCCrypto.sizeof_error_msg(),
);
const songId = QMCCrypto.UTF8ToString(pDetectionResult + QMCCrypto.offsetof_song_id(), QMCCrypto.sizeof_song_id());
if (!songId) {
console.debug('qmc2-wasm: songId not found');
} else if (/^\d+$/.test(songId)) {
result.songId = songId;
} else {
console.warn('qmc2-wasm: Invalid songId: %s', songId);
}
// 释放内存
QMCCrypto._free(pDetectionBuf);
QMCCrypto._free(pDetectionResult);
if (!detectOK) {
return result;
}
// 计算解密后文件的大小。
// 之前得到的 position 为相对当前检测数据起点的偏移。
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
// 提取嵌入到文件的 EKey
const ekey = new Uint8Array(mggBlob.slice(decryptedSize, decryptedSize + len));
// 解码 UTF-8 数据到 string
const decoder = new TextDecoder();
const ekey_b64 = decoder.decode(ekey);
// 初始化加密与缓冲区
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64);
const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE);
const decryptedParts = [];
let offset = 0;
let bytesToDecrypt = decryptedSize;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(mggBlob.slice(offset, offset + blockSize));
QMCCrypto.writeArrayToMemory(blockData, buf);
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
QMCCrypto._free(buf);
hCrypto.delete();
result.data = MergeUint8Array(decryptedParts);
return result;
}

View File

@@ -1,51 +1,50 @@
import { import {
AudioMimeType, AudioMimeType,
GetArrayBuffer, GetArrayBuffer,
GetCoverFromFile, GetCoverFromFile,
GetMetaFromFile, GetMetaFromFile,
SniffAudioExt, SniffAudioExt,
SplitFilename SplitFilename,
} from "@/decrypt/utils.ts"; } 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,
artist, artist,
ext, ext,
album: tag.common.album, album: tag.common.album,
picture: GetCoverFromFile(tag), picture: GetCoverFromFile(tag),
file: URL.createObjectURL(audioBlob), file: URL.createObjectURL(audioBlob),
blob: audioBlob, blob: audioBlob,
mime: AudioMimeType[ext] mime: AudioMimeType[ext],
} };
} }

View File

@@ -1,28 +1,32 @@
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts"; 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,
let ext = raw_ext; raw_filename: string,
if (detect) { raw_ext: string,
const buffer = new Uint8Array(await GetArrayBuffer(file)); detect: boolean = true,
ext = SniffAudioExt(buffer, raw_ext); ): Promise<DecryptResult> {
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]}) let ext = raw_ext;
} if (detect) {
const tag = await metaParseBlob(file); const buffer = new Uint8Array(await GetArrayBuffer(file));
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist) ext = SniffAudioExt(buffer, raw_ext);
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
}
const tag = await metaParseBlob(file);
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist);
return { return {
title, title,
artist, artist,
ext, ext,
album: tag.common.album, album: tag.common.album,
picture: GetCoverFromFile(tag), picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file), file: URL.createObjectURL(file),
blob: file, blob: file,
mime: AudioMimeType[ext] mime: AudioMimeType[ext],
} };
} }

View File

@@ -1,14 +1,14 @@
import {Decrypt as RawDecrypt} from "./raw"; import { Decrypt as RawDecrypt } from './raw';
import {GetArrayBuffer} from "@/decrypt/utils.ts"; 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];
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
const audioData = new Uint8Array(await GetArrayBuffer(file)); const audioData = new Uint8Array(await GetArrayBuffer(file));
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);
} }

View File

@@ -1,170 +1,178 @@
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];
//prettier-ignore
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 {
if (a.length !== b.length) return false;
return a.every((val, 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 && if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a';
BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a" if (BytesHasPrefix(data, WAV_HEADER)) return 'wav';
if (BytesHasPrefix(data, WAV_HEADER)) return "wav" if (BytesHasPrefix(data, WMA_HEADER)) return 'wma';
if (BytesHasPrefix(data, WMA_HEADER)) return "wma" if (BytesHasPrefix(data, AAC_HEADER)) return 'aac';
if (BytesHasPrefix(data, AAC_HEADER)) return "aac" if (BytesHasPrefix(data, DFF_HEADER)) return 'dff';
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);
}); });
} }
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) {
if (!meta.artist) meta.artist = items[0].trim(); if (!meta.artist) meta.artist = items[0].trim();
if (!meta.title) meta.title = items[1].trim(); if (!meta.title) meta.title = items[1].trim();
} else if (items.length === 1) { } else if (items.length === 1) {
if (!meta.title) meta.title = items[0].trim(); if (!meta.title) meta.title = items[0].trim();
}
return meta;
}
export async function GetImageFromURL(
src: string,
): Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
try {
const resp = await fetch(src);
const mime = resp.headers.get('Content-Type');
if (mime?.startsWith('image/')) {
const buffer = await resp.arrayBuffer();
const url = URL.createObjectURL(new Blob([buffer], { type: mime }));
return { buffer, url, mime };
} }
return meta } catch (e) {
console.warn(e);
}
} }
export async function GetImageFromURL(src: string):
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
try {
const resp = await fetch(src);
const mime = resp.headers.get("Content-Type");
if (mime?.startsWith("image/")) {
const buffer = await resp.arrayBuffer();
const url = URL.createObjectURL(new Blob([buffer], {type: mime}))
return {buffer, url, mime}
}
} catch (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
writer.setFrame('TPE1', old?.artists || info.artists || [])
.setFrame('TIT2', old?.title || info.title)
.setFrame('TALB', old?.album || info.album || "");
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || "Cover",
})
} }
return writer.addTag(); });
const old = original.common;
writer
.setFrame('TPE1', old?.artists || info.artists || [])
.setFrame('TIT2', old?.title || info.title)
.setFrame('TALB', old?.album || info.album || '');
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || '',
});
}
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),
} };
} }

View File

@@ -1,66 +1,67 @@
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.ts"; 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,
artist, artist,
ext, ext,
mime, mime,
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob), file: URL.createObjectURL(musicBlob),
blob: musicBlob, blob: musicBlob,
rawExt: "xm" rawExt: 'xm',
} };
} }

View File

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

View File

@@ -1,25 +0,0 @@
//TODO: Use other method to fix this
// !! Only Temporary Solution
// it seems like that @babel/plugin-proposal-object-rest-spread not working
// to fix up the compatibility for Edge 18 and some older Chromium
// now manually edit the dependency files
const fs = require('fs');
const filePath = "./node_modules/file-type/core.js";
const regReplace = /{\s*([a-zA-Z0-9:,\s]*),\s*\.\.\.([a-zA-Z0-9]*)\s*};/m;
if (fs.existsSync(filePath)) {
console.log("File Found!");
let data = fs.readFileSync(filePath).toString();
const regResult = regReplace.exec(data);
if (regResult != null) {
data = data.replace(regResult[0],
"Object.assign({ " + regResult[1] + " }, " + regResult[2] + ");"
);
fs.writeFileSync(filePath, data);
console.log("Object rest spread in file-type fixed!");
} else {
console.log("No fix needed.");
}
} else {
console.log("File Not Found!");
}

View File

@@ -1,31 +1,39 @@
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,
Col, Col,
Container, Container,
Footer, Dialog,
Icon, Form,
Image, FormItem,
Link, Footer,
Main, Icon,
Notification, Image,
Progress, Input,
Radio, Link,
Row, Main,
Table, Notification,
TableColumn, Progress,
Tooltip, Radio,
Upload, Row,
MessageBox Table,
TableColumn,
Tooltip,
Upload,
MessageBox,
} from 'element-ui'; } from 'element-ui';
import 'element-ui/lib/theme-chalk/base.css'; 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);
@@ -44,5 +52,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');

View File

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

View File

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

48
src/shims-fs.d.ts vendored
View File

@@ -1,58 +1,54 @@
export interface FileSystemGetFileOptions { export interface FileSystemGetFileOptions {
create?: boolean create?: boolean;
} }
interface FileSystemCreateWritableOptions { interface FileSystemCreateWritableOptions {
keepExistingData?: boolean keepExistingData?: boolean;
} }
interface FileSystemRemoveOptions { interface FileSystemRemoveOptions {
recursive?: boolean recursive?: boolean;
} }
interface FileSystemFileHandle { interface FileSystemFileHandle {
getFile(): Promise<File>; getFile(): Promise<File>;
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream> createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
} }
enum WriteCommandType { enum WriteCommandType {
write = "write", write = 'write',
seek = "seek", seek = 'seek',
truncate = "truncate", truncate = 'truncate',
} }
interface WriteParams { interface WriteParams {
type: WriteCommandType type: WriteCommandType;
size?: number size?: number;
position?: number position?: number;
data: BufferSource | Blob | string data: BufferSource | Blob | string;
} }
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams;
interface FileSystemWritableFileStream extends WritableStream { interface FileSystemWritableFileStream extends WritableStream {
write(data: FileSystemWriteChunkType): Promise<undefined> write(data: FileSystemWriteChunkType): Promise<undefined>;
seek(position: number): Promise<undefined> seek(position: number): Promise<undefined>;
truncate(size: number): Promise<undefined> truncate(size: number): Promise<undefined>;
close(): Promise<undefined> // should be implemented in WritableStream close(): Promise<undefined>; // should be implemented in WritableStream
} }
export declare interface FileSystemDirectoryHandle { export declare interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle> getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>;
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>;
} }
declare global { declare global {
interface Window { interface Window {
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>;
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle> }
}
} }

10
src/shims-tsx.d.ts vendored
View File

@@ -1,17 +1,15 @@
import Vue, {VNode} from 'vue' import Vue, { VNode } from 'vue';
declare global { declare global {
namespace JSX { namespace JSX {
// tslint:disable no-empty-interface // tslint:disable no-empty-interface
interface Element extends VNode { interface Element extends VNode {}
}
// tslint:disable no-empty-interface // tslint:disable no-empty-interface
interface ElementClass extends Vue { interface ElementClass extends Vue {}
}
interface IntrinsicElements { interface IntrinsicElements {
[elem: string]: any [elem: string]: any;
} }
} }
} }

4
src/shims-vue.d.ts vendored
View File

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

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

View File

@@ -0,0 +1 @@
export const extractQQMusicMeta = jest.fn();

View File

@@ -0,0 +1,4 @@
export const storage = {
loadJooxUUID: jest.fn(),
saveJooxUUID: jest.fn(),
};

View File

@@ -1,56 +1,113 @@
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) {
return fetch(IXAREA_API_ENDPOINT + "/qmcmask/usage", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
Mask: Base64Encode(new Uint8Array(maskData)), Key: Base64Encode(keyData),
Artist: artist, Title: title, Album: album, Filename: filename, Format: format
}),
})
}
interface KeyInfo {
Matrix44: string
}
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> {
const resp = await fetch(IXAREA_API_ENDPOINT + "/qmcmask/query", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44}),
});
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();
}
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
View 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
View File

@@ -0,0 +1,3 @@
import storageFactory from './storage/StorageFactory';
export const storage = storageFactory();

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

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

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

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

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

73
src/utils/tea.test.ts Normal file
View File

@@ -0,0 +1,73 @@
// Copyright 2021 MengYX. All rights reserved.
//
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in https://go.dev/LICENSE.
import { TeaCipher } from '@/utils/tea';
test('key size', () => {
// prettier-ignore
const testKey = new Uint8Array([
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
0x00,
])
expect(() => new TeaCipher(testKey.slice(0, 16))).not.toThrow();
expect(() => new TeaCipher(testKey)).toThrow();
expect(() => new TeaCipher(testKey.slice(0, 15))).toThrow();
});
// prettier-ignore
const teaTests = [
// These were sourced from https://github.com/froydnj/ironclad/blob/master/testing/test-vectors/tea.testvec
{
rounds: TeaCipher.numRounds,
key: new Uint8Array([
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]),
cipherText: new Uint8Array([0x41, 0xea, 0x3a, 0x0a, 0x94, 0xba, 0xa9, 0x40]),
},
{
rounds: TeaCipher.numRounds,
key: new Uint8Array([
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]),
cipherText: new Uint8Array([0x31, 0x9b, 0xbe, 0xfb, 0x01, 0x6a, 0xbd, 0xb2]),
},
{
rounds: 16,
key: new Uint8Array([
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]),
cipherText: new Uint8Array([0xed, 0x28, 0x5d, 0xa1, 0x45, 0x5b, 0x33, 0xc1]),
},
];
test('rounds', () => {
const tt = teaTests[0];
expect(() => new TeaCipher(tt.key, tt.rounds - 1)).toThrow();
});
test('encrypt & decrypt', () => {
for (const tt of teaTests) {
const c = new TeaCipher(tt.key, tt.rounds);
const buf = new Uint8Array(8);
const bufView = new DataView(buf.buffer);
c.encrypt(bufView, new DataView(tt.plainText.buffer));
expect(buf).toStrictEqual(tt.cipherText);
c.decrypt(bufView, new DataView(tt.cipherText.buffer));
expect(buf).toStrictEqual(tt.plainText);
}
});

80
src/utils/tea.ts Normal file
View File

@@ -0,0 +1,80 @@
// Copyright 2021 MengYX. All rights reserved.
//
// Copyright 2015 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in https://go.dev/LICENSE.
// TeaCipher is a typescript port to golang.org/x/crypto/tea
// Package tea implements the TEA algorithm, as defined in Needham and
// Wheeler's 1994 technical report, “TEA, a Tiny Encryption Algorithm”. See
// http://www.cix.co.uk/~klockstone/tea.pdf for details.
//
// TEA is a legacy cipher and its short block size makes it vulnerable to
// birthday bound attacks (see https://sweet32.info). It should only be used
// where compatibility with legacy systems, not security, is the goal.
export class TeaCipher {
// BlockSize is the size of a TEA block, in bytes.
static readonly BlockSize = 8;
// KeySize is the size of a TEA key, in bytes.
static readonly KeySize = 16;
// delta is the TEA key schedule constant.
static readonly delta = 0x9e3779b9;
// numRounds 64 is the standard number of rounds in TEA.
static readonly numRounds = 64;
k0: number;
k1: number;
k2: number;
k3: number;
rounds: number;
constructor(key: Uint8Array, rounds: number = TeaCipher.numRounds) {
if (key.length != 16) {
throw Error('incorrect key size');
}
if ((rounds & 1) != 0) {
throw Error('odd number of rounds specified');
}
const k = new DataView(key.buffer);
this.k0 = k.getUint32(0, false);
this.k1 = k.getUint32(4, false);
this.k2 = k.getUint32(8, false);
this.k3 = k.getUint32(12, false);
this.rounds = rounds;
}
encrypt(dst: DataView, src: DataView) {
let v0 = src.getUint32(0, false);
let v1 = src.getUint32(4, false);
let sum = 0;
for (let i = 0; i < this.rounds / 2; i++) {
sum = sum + TeaCipher.delta;
v0 += ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1);
v1 += ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3);
}
dst.setUint32(0, v0, false);
dst.setUint32(4, v1, false);
}
decrypt(dst: DataView, src: DataView) {
let v0 = src.getUint32(0, false);
let v1 = src.getUint32(4, false);
let sum = (TeaCipher.delta * this.rounds) / 2;
for (let i = 0; i < this.rounds / 2; i++) {
v1 -= ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3);
v0 -= ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1);
sum -= TeaCipher.delta;
}
dst.setUint32(0, v0, false);
dst.setUint32(4, v1, false);
}
}

View File

@@ -1,79 +1,80 @@
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,
TitleOnly, TitleOnly,
TitleAndArtist, TitleAndArtist,
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) {
case FilenamePolicy.TitleOnly: case FilenamePolicy.TitleOnly:
return `${data.title}.${data.ext}`; return `${data.title}.${data.ext}`;
case FilenamePolicy.TitleAndArtist: case FilenamePolicy.TitleAndArtist:
return `${data.title} - ${data.artist}.${data.ext}`; return `${data.title} - ${data.artist}.${data.ext}`;
case FilenamePolicy.SameAsOriginal: case FilenamePolicy.SameAsOriginal:
return `${data.rawFilename}.${data.ext}`; return `${data.rawFilename}.${data.ext}`;
default: default:
case FilenamePolicy.ArtistAndTitle: case FilenamePolicy.ArtistAndTitle:
return `${data.artist} - ${data.title}.${data.ext}`; return `${data.artist} - ${data.title}.${data.ext}`;
} }
} }
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();
} }
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);
} }
} }
export class DecryptQueue { 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);
}
} }

View File

@@ -1,4 +1,4 @@
import {expose} from "threads/worker"; import { expose } from 'threads/worker';
import {CommonDecrypt} from "@/decrypt/common"; import { Decrypt } from '@/decrypt';
expose(CommonDecrypt) expose(Decrypt);

View File

@@ -1,157 +1,170 @@
<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> <el-row>
<el-row> <config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog>
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button> <el-tooltip class="item" effect="dark" placement="top">
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button> <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>
<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>
<el-checkbox v-model="instant_save" border class="ml-2">立即保存</el-checkbox> <el-checkbox v-model="instant_save" border class="ml-2">立即保存</el-checkbox>
</el-tooltip> </el-tooltip>
</el-row> </el-row>
</div>
<audio :autoplay="playing_auto" :src="playing_url" controls/>
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying"/>
</div> </div>
<audio :autoplay="playing_auto" :src="playing_url" controls />
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying" />
</div>
</template> </template>
<script> <script>
import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable';
import ConfigDialog from '@/component/ConfigDialog';
import FileSelector from "@/component/FileSelector" import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
import PreviewTable from "@/component/PreviewTable"
import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
FileSelector, FileSelector,
PreviewTable PreviewTable,
ConfigDialog,
},
data() {
return {
showConfigDialog: false,
tableData: [],
playing_url: '',
playing_auto: false,
filename_policy: FilenamePolicy.ArtistAndTitle,
instant_save: false,
FilenamePolicies,
dir: null,
};
},
watch: {
instant_save(val) {
if (val) this.showDirectlySave();
}, },
data() { },
return { methods: {
tableData: [], async showSuccess(data) {
playing_url: "", if (this.instant_save) {
playing_auto: false, await this.saveFile(data);
filename_policy: FilenamePolicy.ArtistAndTitle, RemoveBlobMusic(data);
instant_save: false, } else {
FilenamePolicies, this.tableData.push(data);
dir: null this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000,
});
}
if (process.env.NODE_ENV === 'production') {
let _rp_data = [data.title, data.artist, data.album];
window._paq.push(['trackEvent', 'Unlock', data.rawExt + ',' + data.mime, JSON.stringify(_rp_data)]);
}
},
showFail(errInfo, filename) {
console.error(errInfo, filename);
this.$notify.error({
title: '出现问题',
message:
errInfo +
'' +
filename +
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 6000,
});
if (process.env.NODE_ENV === 'production') {
window._paq.push(['trackEvent', 'Error', String(errInfo), filename]);
}
},
changePlaying(url) {
this.playing_url = url;
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach((value) => {
RemoveBlobMusic(value);
});
this.tableData = [];
},
handleDecryptionConfig() {
this.showConfigDialog = true;
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
this.saveFile(this.tableData[index]);
index++;
} else {
clearInterval(c);
} }
}, 300);
}, },
watch: {
instant_save(val) {
if (val) this.showDirectlySave()
}
},
methods: {
async showSuccess(data) {
if (this.instant_save) {
await this.saveFile(data)
RemoveBlobMusic(data);
} else {
this.tableData.push(data);
this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000
});
}
if (process.env.NODE_ENV === 'production') {
let _rp_data = [data.title, data.artist, data.album];
window._paq.push(["trackEvent", "Unlock", data.rawExt + "," + data.mime, JSON.stringify(_rp_data)]);
}
},
showFail(errInfo, filename) {
console.error(errInfo, filename)
this.$notify.error({
title: '出现问题',
message: errInfo + "" + filename +
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 6000
});
if (process.env.NODE_ENV === 'production') {
window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
}
},
changePlaying(url) {
this.playing_url = url;
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach(value => {
RemoveBlobMusic(value);
});
this.tableData = [];
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
this.saveFile(this.tableData[index])
index++;
} else {
clearInterval(c);
}
}, 300);
},
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() {
if (!window.showDirectoryPicker) return
try {
await this.$confirm("您的浏览器支持文件直接保存到磁盘,是否使用?",
"新特性提示", {
confirmButtonText: "使用",
cancelButtonText: "不使用",
type: "warning",
center: true
})
} catch (e) {
console.log(e)
return
}
try {
this.dir = await window.showDirectoryPicker()
const test_filename = "__unlock_music_write_test.txt"
await this.dir.getFileHandle(test_filename, {create: true})
await this.dir.removeEntry(test_filename)
} catch (e) {
console.error(e)
}
}
}, },
} async showDirectlySave() {
if (!window.showDirectoryPicker) return;
try {
await this.$confirm('您的浏览器支持文件直接保存到磁盘,是否使用?', '新特性提示', {
confirmButtonText: '使用',
cancelButtonText: '不使用',
type: 'warning',
center: true,
});
} catch (e) {
console.log(e);
return;
}
try {
this.dir = await window.showDirectoryPicker();
const test_filename = '__unlock_music_write_test.txt';
await this.dir.getFileHandle(test_filename, { create: true });
await this.dir.removeEntry(test_filename);
} catch (e) {
console.error(e);
}
},
},
};
</script> </script>

1
testdata/mflac0_rc4_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5

1
testdata/mflac0_rc4_key_raw.bin vendored Normal file
View File

@@ -0,0 +1 @@
ZFJ6WDNwNVrjEJZB1o6QjkQV2ZbHSw/2Eb00q1+4z9SVWYyFWO1PcSQrJ5326ubLklmk2ab3AEyIKNUu8DFoAoAc9dpzpTmc+pdkBHjM/bW2jWx+dCyC8vMTHE+DHwaK14UEEGW47ZXMDi7PRCQ2Jpm/oXVdHTIlyrc+bRmKfMith0L2lFQ+nW8CCjV6ao5ydwkZhhNOmRdrCDcUXSJH9PveYwra9/wAmGKWSs9nemuMWKnbjp1PkcxNQexicirVTlLX7PVgRyFyzNyUXgu+R2S4WTmLwjd8UsOyW/dc2mEoYt+vY2lq1X4hFBtcQGOAZDeC+mxrN0EcW8tjS6P4TjOjiOKNMxIfMGSWkSKL3H7z5K7nR1AThW20H2bP/LcpsdaL0uZ/js1wFGpdIfFx9rnLC78itL0WwDleIqp9TBMX/NwakGgIPIbjBwfgyD8d8XKYuLEscIH0ZGdjsadB5XjybgdE3ppfeFEcQiqpnodlTaQRm3KDIF9ATClP0mTl8XlsSojsZ468xseS1Ib2iinx/0SkK3UtJDwp8DH3/+ELisgXd69Bf0pve7wbrQzzMUs9/Ogvvo6ULsIkQfApJ8cSegDYklzGXiLNH7hZYnXDLLSNejD7NvQouULSmGsBbGzhZ5If0NP/6AhSbpzqWLDlabTDgeWWnFeZpBnlK6SMxo+YFFk1Y0XLKsd69+jj

BIN
testdata/mflac0_rc4_raw.bin vendored Normal file

Binary file not shown.

BIN
testdata/mflac0_rc4_suffix.bin vendored Normal file

Binary file not shown.

BIN
testdata/mflac0_rc4_target.bin vendored Normal file

Binary file not shown.

1
testdata/mflac_map_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
yw7xWOyNQ8585Jwx3hjB49QLPKi38F89awnrQ0fq66NT9TDq1ppHNrFqhaDrU5AFk916D58I53h86304GqOFCCyFzBem68DqiXJ81bILEQwG3P3MOnoNzM820kNW9Lv9IJGNn9Xo497p82BLTm4hAX8JLBs0T2pilKvT429sK9jfg508GSk4d047Jxdz5Fou4aa33OkyFRBU3x430mgNBn04Lc9BzXUI2IGYXv3FGa9qE4Vb54kSjVv8ogbg47j3

1
testdata/mflac_map_key_raw.bin vendored Normal file
View File

@@ -0,0 +1 @@
eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88=

BIN
testdata/mflac_map_raw.bin vendored Normal file

Binary file not shown.

BIN
testdata/mflac_map_suffix.bin vendored Normal file

Binary file not shown.

BIN
testdata/mflac_map_target.bin vendored Normal file

Binary file not shown.

1
testdata/mflac_rc4_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
pUtyvqr0TgAvR95mNmY7DmNl386TsJNAEIz95CEcgIgJCcs28686O7llxD5E74ldn70xMtd5cG58TA5ILw09I8BOTf5EdHKd6wwPn689DUK13y3Req6H0P33my2miJ5bQ2AA22B8vp4V0NJ3hBqNtFf7cId48V6W51e1kwgu1xKKawxe9BByT92MFlqrFaKH32dB2zFgyd38l2P1outr4l2XLq48F9G17ptRz4W8Loxu28RvZgv0BzL26Ht9I2L5VCwMzzt7OeZ55iQs40Tr6k81QGraIUJj5zeBMgJRMTaSgi19hU5x5a08Qd662MbFhZZ0FjVvaDy1nbIDhrC62c1lX6wf70O45h4W42VxloBVeZ9Sef4V7cWrjrEjj3DJ5w2iu6Q9uoal2f4390kue42Um5HcDFWqv3m56k6O89bRV424PaRra1k9Cd2L56IN2zfBYqNo2WP5VC68G8w1hfflOY0O52h4WdcpoHSjZm4b35N7l47dT4dwEXj1U4J5

1
testdata/mflac_rc4_key_raw.bin vendored Normal file
View File

@@ -0,0 +1 @@
cFV0eXZxcjAF/IXJ9qJT1u5C3S5AgY9BoVtIQNBKfxQMt5hH7BF36ndIJGV5L6qw5h4G0IOIOOewdHmMCNfKJftHM4nv3B0iRlSdqJKdL08wO3sV0v8eZk0OiYAlxgseGcBquQWYS/0b5Lj/Ioi2NfpOthAY9vUiRPnfH3+7/2AJGudHjj4Gg1KkpPW3mXIKbsk+Ou9fhrUqs873BCdsmI6qRmVNhOkLaUcbG6Zin3XU0WkgnnjebR43S8N4bw5BTphFvhy42QvspnD7Ewb1tVZQMQ2N1s38nBjukdfCB9R6aRwITOvg2U7Lr0RjLpbrIn6A6iVilpINjK4VptuKUTlpDXQwgCjoqeHQaHNCWgYpdjB69lXn8km/BfzK7QyDbh0VgTikwAHF9tvPhin3AIDRcU0xsaWYKURRfJelX3pSN495ADlhXdEKL/+l60hVnY7t6iCMxJL3lOtdGtdUYUGUCc76PB1fX+0HTWCcfcwvXTEdczr9J1h2yTeJNqFQ5pNy8vX7Ws8k7vDQVFkw4llZjPhb0kg9aDNePTNIKSGwy/7eofrcUQlC9DI+qqqwQ5abA/93fNsPq6XU3uwawnrbBsdz8DDdjJiEDI7abkPIDIfr/uR0YzgBxW90t5bt6xAtuW+VSYAM7kGxI3RZTl7JgOT60MLyIWkYASrRhRPMGks8zL10ED/4yGTEB1nt

BIN
testdata/mflac_rc4_raw.bin vendored Normal file

Binary file not shown.

BIN
testdata/mflac_rc4_suffix.bin vendored Normal file

Binary file not shown.

BIN
testdata/mflac_rc4_target.bin vendored Normal file

Binary file not shown.

1
testdata/mgg_map_key.bin vendored Normal file
View File

@@ -0,0 +1 @@
zGxNk54pKJ0hDkAo80wHE80ycSWQ7z4m4E846zVy2sqCn14F42Y5S7GqeR11WpOV75sDLbE5dFP992t88l0pHy1yAQ49YK6YX6c543drBYLo55Hc4Y0Fyic6LQPiGqu2bG31r8vaq9wS9v63kg0X5VbnOD6RhO4t0RRhk3ajrA7p0iIy027z0L70LZjtw6E18H0D41nz6ASTx71otdF9z1QNC0JmCl51xvnb39zPExEXyKkV47S6QsK5hFh884QJ

1
testdata/mgg_map_key_raw.bin vendored Normal file
View File

@@ -0,0 +1 @@
ekd4Tms1NHC53JEDO/AKVyF+I0bj0hHB7CZeoLDGSApaQB9Oo/pJTBGA/RO+nk5RXLXdHsffLiY4e8kt3LNo6qMl7S89vkiSFxx4Uoq4bGDJ7Jc+bYL6lLsa3M4sBvXS4XcPChrMDz+LmrJMGG6ua2fYyIz1d6TCRUBf1JJgCIkBbDAEeMVYc13qApitiz/apGAPmAnveCaDhfD5GxWsF+RfQ2OcnvrnIXe80Feh/0jx763DlsOBI3eIede6t5zYHokWkZmVEF1jMrnlvsgbQK2EzUWMblmLMsTKNILyZazEoKUyulqmyLO/c/KYE+USPOXPcbjlYFmLhSGHK7sQB5aBR153Yp+xh61ooh2NGAA=

BIN
testdata/mgg_map_raw.bin vendored Normal file

Binary file not shown.

BIN
testdata/mgg_map_suffix.bin vendored Normal file

Binary file not shown.

BIN
testdata/mgg_map_target.bin vendored Normal file

Binary file not shown.

BIN
testdata/qmc0_static_raw.bin vendored Normal file

Binary file not shown.

0
testdata/qmc0_static_suffix.bin vendored Normal file
View File

BIN
testdata/qmc0_static_target.bin vendored Normal file

Binary file not shown.

View File

@@ -14,7 +14,8 @@
"sourceMap": true, "sourceMap": true,
"baseUrl": ".", "baseUrl": ".",
"types": [ "types": [
"webpack-env" "webpack-env",
"jest"
], ],
"paths": { "paths": {
"@/*": [ "@/*": [