353 Commits

Author SHA1 Message Date
MengYX
c48dbd8a35 chore: update deps & bump version 2022-01-09 10:29:09 +08:00
MengYX
49c53a9c65 docs: update README.md 2022-01-09 10:26:29 +08:00
MengYX
eb6be0d9d1 refactor: change common -> index 2022-01-09 10:22:00 +08:00
MengYX
191ac6a932 fix(QMCv2): overflow error in js decoder 2022-01-09 07:17:22 +08:00
MengYX
53127de968 fix #222 2022-01-09 01:31:12 +08:00
MengYX
6f010d3394 Merge pull request #221 from unlock-music/feature/qmc-meta
feature(QMCv2): 通过 SongID 在线查询歌曲信息
2021-12-25 20:32:33 +08:00
MengYX
eee7d7aedc fix: declare radix in parseInt
Co-authored-by: Jixun Wu <5713045+jixunmoe@users.noreply.github.com>
2021-12-25 20:20:15 +08:00
Jixun Wu
700574fb8e fix(QMCv2): Fail gracefully when WebAssembly loader failed. 2021-12-25 12:07:47 +00:00
Jixun Wu
9ca2d852ce feat(QMCv2): Allow extraction of songId from QMC2-wasm 2021-12-25 12:07:14 +00:00
Jixun Wu
e29b36229e chore: bump qmc2-crypto to v0.0.6-R1 2021-12-25 12:00:35 +00:00
MengYX
7c62f35adb feature: use online info to correct qmc meta 2021-12-25 14:41:05 +08:00
MengYX
f1c8d1ba13 Merge pull request #220 from unlock-music/issue/142
fix #142: remove default mp3 cover description
2021-12-25 12:18:58 +08:00
MengYX
4bca64b4b8 fix #142: remove default mp3 cover description 2021-12-25 12:13:26 +08:00
MengYX
f71203a157 Merge pull request #215 from jixunmoe/feature/joox-encryption
提供 joox 解密/meta 更新支持
2021-12-25 11:49:24 +08:00
MengYX
ce3de22d0e fix: api path & docker image name 2021-12-25 11:46:01 +08:00
Jixun Wu
48b8194363 test(joox): Added basic sanity test for joox encryption. 2021-12-23 23:23:32 +00:00
Jixun Wu
4af1a38334 feat(joox): Fetch meta data from API 2021-12-23 14:58:24 +00:00
Jixun Wu
68782f0ec5 doc: reforamtted & updated content in readme 2021-12-23 14:57:10 +00:00
Jixun Wu
95df64516c fix: avoid "ArtiomTr/jest-coverage-report-action" when running from a fork. 2021-12-22 14:10:29 +00:00
Jixun Wu
ea99d38a92 refactor: move ruby to custom vue component 2021-12-22 13:39:47 +00:00
Jixun Wu
afab80505e chore: remove left-over debugger statement 2021-12-22 13:39:37 +00:00
Jixun Wu
c20ce54dac fix: form validation on input change 2021-12-22 13:28:38 +00:00
Jixun Wu
2fe1e40957 Merge remote-tracking branch 'upstream/master' into feature/joox-encryption 2021-12-22 13:20:36 +00:00
MengYX
058985de4f fix(ci): test coverage annotation failed 2021-12-22 14:48:54 +08:00
Jixun Wu
8a323f9dbb feat(config): better config ui
- JOOX Music UUID label + description
- Not full screen anymore
2021-12-21 23:10:34 +00:00
Jixun Wu
2e946e6e30 feat(joox): re-use QM meta extraction code 2021-12-21 22:34:36 +00:00
Jixun Wu
b6497e2bd3 refactor(qmc): extract qm meta code to utils 2021-12-21 22:34:18 +00:00
Jixun Wu
3884158f06 fix: only pass over config settings 2021-12-21 22:17:43 +00:00
Jixun Wu
36d616398e feat(storage): Pass over config to worker thread on decryption call 2021-12-20 22:22:35 +00:00
Jixun Wu
ed84a4732d refactor: storage factory + singleton
- Make storage easier.
2021-12-20 22:19:44 +00:00
Jixun
bae9a7fec0 fix: storage read/write in chrome extension 2021-12-20 19:13:27 +00:00
Jixun
3fb0e1eb0f fix: add missing permission for chrome storage 2021-12-20 19:13:17 +00:00
Jixun
2da37f984a chore: bump to 0.0.1-R4 2021-12-20 17:42:09 +00:00
Jixun
53a2073cb4 fix: crash due to chrome been undefined 2021-12-19 23:43:38 +00:00
Jixun
b46d9fa720 chore: bump joox-crypto dependency 2021-12-19 23:07:46 +00:00
Jixun
699333ca06 feat: add basic joox support 2021-12-19 23:03:46 +00:00
MengYX
0e06549e04 Merge pull request #213 from unlock-music/prettier
Add: Prettier
2021-12-18 22:44:34 +08:00
MengYX
edc4e4864b pretty: ignore matrix 2021-12-18 22:42:13 +08:00
MengYX
3727f67e40 chore: remove unused api 2021-12-18 22:33:43 +08:00
MengYX
cad5b4d7de all: format with prettier 2021-12-18 22:33:43 +08:00
MengYX
559be402c9 maintenance: add prettier 2021-12-18 22:33:42 +08:00
MengYX
4be8a749f4 Merge pull request #214 from unlock-music/fix/qmc-cipher
fix(QMCv2): cipher should determine by key size
2021-12-18 22:32:29 +08:00
MengYX
dba63f212c fix(QMCv2): cipher should determine by key size 2021-12-18 21:35:22 +08:00
MengYX
bd66016cb5 Merge pull request #211 from unlock-music/feature/qmc-v2
Feature: QMC v2 (JS Decoder)
2021-12-17 17:58:47 +08:00
MengYX
8713871854 chore(QMCv2): fix code style 2021-12-17 08:41:44 +08:00
MengYX
c24e5d2973 feat(QMCv2): use js decoder 2021-12-17 06:24:21 +08:00
MengYX
29ac94d1fe feat(QMCv2): add decoder 2021-12-17 06:07:43 +08:00
MengYX
6b5b4d3bf5 feat(QMCv2): add rc4 cipher 2021-12-17 03:53:12 +08:00
MengYX
a9aaa40ec4 feat(QMCv2): add key decrypt 2021-12-17 01:28:48 +08:00
MengYX
7306bf031f feat(QMCv2): add map cipher 2021-12-16 23:07:59 +08:00
MengYX
c2c89a423f test(QMCv2): coverage standard TEA cipher 2021-12-16 23:07:13 +08:00
MengYX
24422b216a feat(QMCv2): add standard TEA cipher 2021-12-16 23:07:08 +08:00
MengYX
3fd35b5d30 fix(extension): version string must be numbers and dots 2021-12-16 10:45:32 +08:00
MengYX
f9f5e32b44 fix: ci 2021-12-16 08:52:22 +08:00
MengYX
525ddfae31 maintenance: update ci 2021-12-16 08:19:23 +08:00
MengYX
10e35c5d3e maintenance: update ci 2021-12-16 08:16:04 +08:00
MengYX
12e3f91a1e chore: bump version 2021-12-16 08:03:08 +08:00
MengYX
cb92eed9b1 maintenance: update ci 2021-12-16 07:59:09 +08:00
MengYX
3960ea7d59 maintenance: update ci 2021-12-16 07:25:10 +08:00
MengYX
af20e8a697 maintenance: remove fix-compatibility.js 2021-12-16 07:24:42 +08:00
MengYX
112f2200d7 Merge pull request #208 from unlock-music/feature/refactor-qmc-v1
feat(QMC): use static cipher instead of mask
2021-12-16 07:13:59 +08:00
MengYX
cd6b84ad7e feat: use static cipher instead of mask 2021-12-16 07:07:51 +08:00
MengYX
a14434b956 Merge pull request #207 from jixunmoe/feature/qmcv2-wasm
WIP: 实验 qmc2-crypto 包
2021-12-16 06:35:06 +08:00
Jixun Wu
9470f2ca87 chore: add eol at the end of qmcv2.ts. 2021-12-15 22:20:53 +00:00
Jixun Wu
bdd60bc502 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 3b88d168b6
2021-12-15 22:19:24 +00:00
Jixun Wu
0f3cd9b67f Revert "chore: upgrade qmc2-crypto to 0.0.5-R4"
This reverts commit 3b88d168b6.

It generates unexpected large diff in package-lock.json.
2021-12-15 22:16:15 +00:00
Jixun Wu
3b88d168b6 chore: upgrade qmc2-crypto to 0.0.5-R4
- Remove the use of `new Function` in emscripten generated code.
2021-12-15 22:08:25 +00:00
Jixun Wu
41e588e986 fix: treat qmcflac/qmcogg as QMCv2 and fallback to QMCv1 2021-12-15 22:07:05 +00:00
Jixun Wu
5d48b28a94 refactor: remove suppressed qmc mask methods / constants 2021-12-15 20:11:18 +00:00
Jixun Wu
19239f182d refactor: restore support for QMCv1. 2021-12-15 19:59:06 +00:00
Jixun Wu
bdab51bde3 chore: update supported ext list 2021-12-15 19:58:43 +00:00
Jixun Wu
9448b497ed chore: Use QMC2-Crypto with embedded WASM build from 0.0.5-R3 2021-12-15 18:22:05 +00:00
Jixun Wu
4da56bb0fe fix: patch threads to work with production build 2021-12-15 14:02:43 +00:00
Jixun Wu
c8eb1bc481 feat(qmcv2): Experiment with qmc2-crypto 2021-12-15 13:54:35 +00:00
MengYX
1986c1ee41 maintenance: add jest as unit test
(cherry picked from commit ada078df19da90dff701768a3a93f2c8adbc2923)
2021-12-14 15:40:36 +08:00
MengYX
1d510a7afd chore: update deps
(cherry picked from commit 8facd658346c0394216a560696df4bdd9f24fd85)
2021-12-14 15:02:14 +08:00
MengYX
b06b73f43e remove netease: requested by DCMA 2021-10-06 05:07:14 +08:00
MengYX
061392c682 Merge pull request #184 from lvzx123/patch-1
Now it is 2021!
2021-09-25 15:27:57 +08:00
lvzx123
64b51560af Now it is 2021!
大人,时代变了
2021-09-25 08:56:07 +08:00
MengYX
f6d71dc6a2 bump version 2021-08-27 10:01:05 +08:00
MengYX
19b56d1893 fix #169 2021-08-25 01:04:46 +08:00
MengYX
c7181b264f fix #179 2021-08-24 23:55:44 +08:00
MengYX
262c8fa234 fix #179 2021-08-24 23:13:19 +08:00
Emmm Monster
caec454d3b fix(extension): compute version name 2021-08-11 23:04:01 +08:00
Emmm Monster
7cc143d04c chore: bump version & update deps 2021-08-08 08:08:56 +08:00
MengYX
84fd8d2305 change ixarea api endpoint 2021-08-08 07:56:28 +08:00
MengYX
86b543840d optimize: .kgm mask loading 2021-08-08 07:47:28 +08:00
sunhao03
bab5a577da fetch mask file fix on production 2021-08-07 22:13:00 +08:00
Emmm Monster
cffadee581 chore: update deps & fix audit 2021-07-01 01:33:28 +08:00
Emmm Monster
10380d6274 fix: avoid using worker in file protocol 2021-07-01 01:29:04 +08:00
Emmm Monster
af34f64a52 chore(ci): build after *.ts changes 2021-06-03 13:15:04 +08:00
Emmm Monster
ae3c080079 simplify: decrypt/ncm-cache & decrypt/common 2021-06-03 13:09:14 +08:00
Emmm Monster
13e1499e42 fix: decrypt/qmc-cache
adapt: decrypt/qmc for qmc-cache
2021-06-03 13:09:02 +08:00
qq1010903229
6eca3a8405 增加对网易云音乐.uc缓存格式和QQ音乐.cache缓存格式的支持 (#161)
* Update common.ts

* Create ncmcache.ts

* Create qmccache.ts
2021-06-03 13:00:35 +08:00
Emmm Monster
ff5cd344da chore: update deps 2021-05-27 19:53:19 +08:00
Emmm Monster
273e973f32 fix: .vpr/.kgm fail in worker 2021-05-27 19:38:42 +08:00
Emmm Monster
e9b53d880d feature(sniffer): support .dff 2021-05-25 12:27:19 +08:00
Emmm Monster
0a7c99af6f fix: remove test file 2021-05-25 04:36:32 +08:00
Emmm Monster
b3681f162a chore: Bump Version 2021-05-25 03:20:42 +08:00
Emmm Monster
b73848b3a1 feature: directly write to fs 2021-05-25 03:06:28 +08:00
Emmm Monster
3cae9b4cad fixes 2021-05-24 23:48:52 +08:00
Emmm Monster
407d37c770 refactor: component/*.vue 2021-05-24 22:19:37 +08:00
Emmm Monster
e82bbc7aeb refactor(decrypt/*): change interface 2021-05-24 15:05:14 +08:00
Emmm Monster
0be3b29f5d refactor(decrypt/qmc): typescript 2021-05-24 06:50:20 +08:00
Emmm Monster
9e43dbd21a refactor(decrypt/qmc): typescript qmc mask 2021-05-24 05:57:04 +08:00
Emmm Monster
7e0ebf1494 refactor(.ncm): typescript & class 2021-05-24 05:04:16 +08:00
Emmm Monster
9fbe16e155 refactor(typescript): utils.WriteMetaFor{ Mp3, Flac } 2021-05-24 02:55:42 +08:00
Emmm Monster
55fff5d6c7 refactor(typescript): .xm & .kgm 2021-05-24 01:30:38 +08:00
Emmm Monster
f2aa84bfac refactor(typescript): Use ES6 import & use interface 2021-05-23 23:47:01 +08:00
Emmm Monster
901b5cef56 refactor(typescript): utils.GetCoverFromFile & utils.GetMetaFromFile 2021-05-23 23:06:21 +08:00
Emmm Monster
8d9ef9afed feat(decrypt/kwm): support raw .acc 2021-05-23 22:29:34 +08:00
EmmmX
5e3a369609 Merge pull request #157 from unlock-music/add-typescript
Add typescript support
2021-05-23 22:08:23 +08:00
Emmm Monster
dbd0aae3a0 refactor(typescript): utils.GetArrayBuffer 2021-05-23 22:02:36 +08:00
Emmm Monster
fcf0040a16 refactor: move some utils to typescript 2021-05-23 21:40:43 +08:00
Emmm Monster
f4975c0f16 chore: add support for typescript 2021-05-23 21:01:17 +08:00
Emmm Monster
53c1e19cfe chore: update deps & fix audit 2021-05-23 20:41:31 +08:00
MengYX
0373d99fb9 Make Github Dependabot Happy 2021-04-11 13:59:39 +08:00
MengYX
e11e502239 README: Add extension info 2021-02-18 20:08:25 +08:00
MengYX
371b0490dc Remove: [Extension] Stats Code 2021-02-09 15:54:45 +08:00
MengYX
30b8adbb9a Add: [Docs] Docker Usage in README 2021-02-08 20:05:10 +08:00
MengYX
9abb1799f9 Bump Version 2021-02-08 19:28:40 +08:00
MengYX
2639ac3a35 Change: [CI] Action Name 2021-02-08 19:14:26 +08:00
MengYX
ab46eb76d0 Fix: [CI] Build Docker Image 2021-02-08 19:14:12 +08:00
MengYX
6ef8b1fcc6 Fix: [CI] Build Docker Image 2021-02-08 17:09:26 +08:00
MengYX
36f5f9b1e8 Fix: [CI] Build Docker Image 2021-02-08 16:23:24 +08:00
MengYX
ac62fd1122 Update: [CI] Build Docker Image 2021-02-08 16:08:06 +08:00
MengYX
2b16883646 Add: Dockerfile 2021-02-08 14:11:46 +08:00
MengYX
583bb3d8d9 Update: [CI] Remove Cache (because using npm ci) 2021-02-08 14:01:41 +08:00
MengYX
be23dbd3c3 Update: [Docs] README 2021-02-08 12:11:45 +08:00
MengYX
2bb0f25280 Misc: Bump Version 2021-02-08 05:31:16 +08:00
MengYX
1ff5e51afc Fix: [Extension] Use extension API make sure page open successfully 2021-02-08 04:50:00 +08:00
MengYX
9728be6f0d Update: Deps 2021-02-08 04:26:56 +08:00
MengYX
82a211de9a Fix: [CI] Generated zip structure 2021-02-08 04:24:55 +08:00
MengYX
2178a296cb Merge branch 'feature/extension' 2021-02-08 04:14:02 +08:00
MengYX
1429fdf27b Adapt: [Extension] for Firefox 2021-02-08 03:35:26 +08:00
MengYX
4d5c986af9 Fix: [Extension] Remove inline script (for extension's Content Security Policy reason)
Fix: [Extension] Disable Service Worker
2021-02-08 03:05:49 +08:00
MengYX
77f5f06a46 Update CI: Add Extension Build 2021-02-08 01:47:31 +08:00
MengYX
221306f7c5 Add Feature: Browser Extension 2021-02-08 01:28:30 +08:00
MengYX
0c5cae80f0 Change: Web Manifest 2021-02-08 00:26:40 +08:00
MengYX
686f473d55 Update README.md 2021-01-10 16:38:03 +08:00
MengYX
4960322f17 Remove Drone CI 2020-12-23 15:35:46 +08:00
MengYX
1b9bad66ec Remove "By IXarea" 2020-12-23 15:34:37 +08:00
MengYX
f1ba1a3c7e Merge remote-tracking branch 'origin/dependabot/npm_and_yarn/highlight.js-10.4.1' into master 2020-12-19 21:17:27 +08:00
MengYX
1034d54863 Bump Version and Update Deps 2020-12-19 21:16:53 +08:00
MengYX
ad25b3d950 Merge pull request #117 from ix64/fix-qmc-meta
Fix incorrect id3 info for .qmc decryption
2020-12-17 08:36:10 +08:00
MengYX
646bbfb390 Update new-feature.md 2020-12-10 19:01:54 +08:00
MengYX
ac45dc5e47 Update bug-report.md 2020-12-10 19:00:50 +08:00
MengYX
2b55a92f7b Add tips for qmc not writing cover 2020-12-06 02:32:57 +08:00
MengYX
4db4cdbd31 Update CI 2020-12-06 02:22:43 +08:00
MengYX
60fef1c41d Try to fix .qmc ID3 Info 2020-12-06 02:16:45 +08:00
dependabot[bot]
1f41bd8e1f Bump highlight.js from 10.4.0 to 10.4.1
Bumps [highlight.js](https://github.com/highlightjs/highlight.js) from 10.4.0 to 10.4.1.
- [Release notes](https://github.com/highlightjs/highlight.js/releases)
- [Changelog](https://github.com/highlightjs/highlight.js/blob/master/CHANGES.md)
- [Commits](https://github.com/highlightjs/highlight.js/compare/10.4.0...10.4.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-04 18:43:39 +00:00
MengYX
a2e947cbfb Bump Version and Update Deps 2020-11-26 17:28:14 +08:00
MengYX
87a0a0052d Merge pull request #113 from KyleBing/master
调整暗黑模式样式,新增全局统一样式 by @KyleBing
2020-11-26 16:56:44 +08:00
KyleBing
01cd512178 ^ package-lock.json 2020-11-25 14:47:23 +08:00
KyleBing
880da817a6 暗黑模式颜色调整,载入页颜色适配黑色 2020-11-25 14:38:29 +08:00
KyleBing
b954918820 use scss source file, remove pre-compiled css file. 2020-11-25 13:50:38 +08:00
KyleBing
34a74c761d 调整暗黑模式样式,新增全局统一样式 2020-11-24 22:28:56 +08:00
MengYX
1739215a88 Merge pull request #112 from flosacca/master
Fix #100 by @flosacca
2020-11-23 21:34:58 +08:00
flosacca
76629d955b Fix #100 2020-11-21 07:03:57 +08:00
MengYX
45f1e3575e Update README 2020-11-07 22:46:27 +08:00
MengYX
42629d8075 Bump Version 2020-11-07 01:22:45 +08:00
MengYX
846569ea69 Fix #103 #100 duplicated metadata 2020-11-07 01:12:04 +08:00
MengYX
a0233693fb Update CI 2020-11-06 22:40:23 +08:00
MengYX
0c417edebf Update Deps 2020-11-06 22:40:13 +08:00
MengYX
730fa3465f Merge pull request #106 from lc6464/master
适配浏览器深色模式
2020-11-01 11:40:21 +08:00
NULL-LC
d5dc6866af 适配浏览器深色模式 2020-10-31 19:25:14 +08:00
MengYX
eb7cfd72e5 Merge pull request #101 from renbaoshuo/patch-1
更新 Edge 浏览器下载链接
2020-10-30 10:48:45 +08:00
Baoshuo Ren
7788d98af4 更新 Edge 浏览器下载链接 2020-10-18 19:02:40 +08:00
MengYX
a4b98e12ca Bump Version 2020-09-23 16:51:17 +08:00
MengYX
f64f55a3b7 Merge pull request #97 from qq1010903229/patch-1
Merge pull request #97 增加对QQ音乐微云网盘格式的支持
2020-09-23 16:45:54 +08:00
MengYX
1b7e5702be Fix Kgm Decrypt Bug 2020-09-23 14:15:47 +08:00
MengYX
16c24b4d48 Update Deps 2020-09-23 12:55:14 +08:00
MengYX
7906ca8a72 Fix kgm bug 2020-09-23 12:54:54 +08:00
qq1010903229
98af3a34de Update qmc.js 2020-09-19 12:03:02 +08:00
qq1010903229
2dbfc001b2 Update common.js 2020-09-19 12:00:58 +08:00
MengYX
a8925f4dd7 Add Comment for Issue Template 2020-09-04 19:12:58 +08:00
MengYX
03e06b8fcf Bump Version 2020-09-02 00:04:26 +08:00
MengYX
4b0dc87521 Merge remote-tracking branch into master 2020-09-01 23:31:43 +08:00
MengYX
f4b464b47b Add Tips for .kgm while using "file:" protocol 2020-09-01 23:26:02 +08:00
MengYX
9bd9e272dd Use Small Cover Image for .ncm 2020-09-01 23:17:17 +08:00
MengYX
5cbd26fbce Update README.md 2020-08-15 10:30:13 +08:00
MengYX
5bb0e4f770 fix .xm filename detect 2020-08-13 21:27:50 +08:00
MengYX
b2f437f318 Update CI 2020-08-13 21:27:25 +08:00
MengYX
c7254da74c Merge remote-tracking branch 'origin/master' 2020-08-13 16:01:55 +08:00
MengYX
b5cf435de7 Update GitHub CI 2020-08-13 16:01:35 +08:00
MengYX
9eea1aa15f Bump Version and Update Deps 2020-08-13 15:58:31 +08:00
MengYX
e7e065e014 Fix .xm read info from filename 2020-08-13 15:33:15 +08:00
MengYX
5b7a467dae Fix #84 2020-08-13 15:26:15 +08:00
MengYX
8e00ca3bad Fix .xm file type recognize error 2020-08-13 15:25:41 +08:00
MengYX
0f06b0f306 Update issue templates 2020-08-07 19:33:28 +08:00
MengYX
e6691240dd Fix ncm cover image too big to write into meta 2020-08-03 15:04:54 +08:00
MengYX
0b970ca65d Clean up 2020-08-03 14:03:10 +08:00
MengYX
2c8c88fd9a Fix #79 ncm->flac no metadata (file downloaded from phone) 2020-08-03 14:02:17 +08:00
MengYX
2f8fbc2c14 Bump Version 2020-08-02 18:25:56 +08:00
MengYX
a16cdf8732 Fix wrong zip file in release [Skip CI] 2020-08-01 01:20:56 +08:00
MengYX
1322df34d0 Fix #77 ncm flac meta duplicated
Fix #78 write flac cover sometimes fail
2020-08-01 01:10:27 +08:00
MengYX
3339283883 Update README.md 2020-07-19 00:03:41 +08:00
MengYX
03d0d9e8ef Fix GitHub CI 2020-07-18 23:37:07 +08:00
MengYX
d9f00447ef Bump Version 2020-07-18 23:04:26 +08:00
MengYX
0adc933d2f Change IXarea Api Endpoint 2020-07-18 22:43:25 +08:00
MengYX
43b956d8f6 Change IXarea Api Endpoint 2020-07-18 21:58:07 +08:00
MengYX
6c34a3b0ff Add Support qq music cover 2020-07-18 21:45:53 +08:00
MengYX
1d8c5069e4 Add Support for flac meta/cover 2020-07-18 19:25:41 +08:00
MengYX
9c41991cea Write meta for qq music mp3 2020-07-18 18:48:07 +08:00
MengYX
1c21186306 Resolve QQMusic Cover(By IXarea Server) 2020-07-17 00:33:10 +08:00
MengYX
a8c0c742ca Fix dep security problem 2020-07-16 22:44:42 +08:00
MengYX
7029735889 Update Tips 2020-07-16 22:41:22 +08:00
MengYX
9f7a216144 Add Init Support for Kgm/Vpr 2020-07-16 22:22:32 +08:00
MengYX
5fc74d3fe9 Update Deps 2020-07-16 21:28:49 +08:00
MengYX
2d35cb7be9 Update and rename deploy.yml to release.yml 2020-05-28 02:24:32 +08:00
MengYX
4e257fe8ca Create GitHub Pages 2020-05-28 00:41:17 +08:00
MengYX
eeb09edf3a Fix typo #62 2020-05-21 09:36:59 +08:00
MengYX
539ea0448f Bump Version & Update Deps 2020-05-18 16:41:10 +08:00
MengYX
93f46a2950 Add Tips 2020-05-18 16:40:39 +08:00
MengYX
ea9318554e Simplify 2020-05-14 23:46:21 +08:00
MengYX
0476de3d9b Fix ncm unlock while no album pic #58 2020-05-14 17:37:58 +08:00
MengYX
b667dff401 Fix Xiami return info 2020-05-14 17:36:16 +08:00
MengYX
bb61ef90e6 Fix .qmc Files Unlock Error 2020-04-26 15:20:12 +08:00
MengYX
659cb25476 Bump to 1.5.1 2020-04-26 15:17:40 +08:00
MengYX
be7ac71d5c Fix Qmc Mask Query 2020-04-26 15:04:46 +08:00
MengYX
60bc033d9e Small Bug Fix 2020-04-26 11:20:29 +08:00
MengYX
b09beec673 Update Mgg Detect Algorithm 2020-04-26 01:33:52 +08:00
MengYX
e5505df5f5 Change Tips Info 2020-04-26 01:33:52 +08:00
MengYX
803dab8476 Change CI 2020-04-26 01:33:52 +08:00
MengYX
cd29bde7d0 Bug Fix in Worker Mode 2020-04-23 21:23:01 +08:00
MengYX
049ebd90a0 Remove Console Log 2020-04-23 21:16:41 +08:00
MengYX
52de5b3645 Update README.md 2020-04-23 21:12:11 +08:00
MengYX
62212d3005 Update Description and Bump Version
Small Fixes
2020-04-23 21:10:25 +08:00
MengYX
d3d7ef2eb4 Add Support For Xiami .xm Files! 2020-04-23 20:46:08 +08:00
MengYX
3338524db6 Merge remote-tracking branch 'ixarea/master' 2020-04-23 18:17:58 +08:00
MengYX
36ae570ae5 Small Changes 2020-04-23 18:16:33 +08:00
MengYX
2975508b22 Unlock Kuwo .kwm Files! 2020-04-23 18:15:07 +08:00
MengYX
16f4bdd8a4 Update README Tips 2020-04-22 06:43:40 +00:00
MengYX
f85a1d4ed6 Update README.md 2020-04-21 14:31:01 +00:00
MengYX
b9af9bf227 Update README.md 2020-04-09 08:37:54 +00:00
MengYX
521d9f1677 Change Browser Tips Condition 2020-04-08 22:47:38 +08:00
MengYX
f4780f89d4 Update README.md 2020-04-08 14:12:05 +00:00
MengYX
c4dcfa8a65 Limit Update Tips 2020-04-07 19:02:57 +08:00
MengYX
cd4d270641 Bump Version 2020-04-06 18:20:40 +08:00
MengYX
6d9982a7d4 Fix GBK Detect Bug 2020-04-06 13:03:17 +08:00
MengYX
3570cb5c9a Update Deps 2020-04-05 19:26:05 +08:00
MengYX
a0c9f93788 Hello 2020! 2020-04-05 19:23:30 +08:00
MengYX
607478ce4d Fix GBK Encoding Reading in QQMusic Mp3 2020-04-05 19:20:22 +08:00
MengYX
d19bbf682b Get QQMusic Cover URL (Only Display) 2020-04-05 17:56:10 +08:00
MengYX
55df78396d Move ID3 Writer to Util 2020-04-05 17:31:41 +08:00
MengYX
3ab8fb723e Add progress bar for unlocking #37
Add tips for instant save
2020-04-05 12:35:11 +08:00
MengYX
486a6c2624 Add Support For .qmc2 2020-04-05 01:01:59 +08:00
MengYX
2d35b8b468 Add Header Check For .mgg 2020-03-18 23:03:39 +08:00
MengYX
29801f250a Use Npm Registry 2020-03-18 22:45:07 +08:00
MengYX
a22d78b740 Merge pull request #31
Bump acorn from 6.4.0 to 6.4.1 to fix a security vulnerability
2020-03-15 23:09:18 +08:00
dependabot[bot]
20c8a1d963 Bump acorn from 6.4.0 to 6.4.1
Bumps [acorn](https://github.com/acornjs/acorn) from 6.4.0 to 6.4.1.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/6.4.0...6.4.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-03-15 14:56:29 +00:00
MengYX
fd3cba6c50 Bump Version 2020-03-12 18:56:21 +08:00
MengYX
340c66ec7e Merge CI Settings 2020-03-10 12:22:46 +08:00
MengYX
c968a7578a Fix errors when parsing ncm files without metadata 2020-03-10 09:31:48 +08:00
MengYX
74f8cc8d2a Remove Source Map in production 2020-03-04 10:00:18 +08:00
MengYX
dc8c126cd3 Update CI 2020-03-04 09:38:30 +08:00
MengYX
82915a9dd5 Bump Version 2020-03-04 09:30:48 +08:00
MengYX
8953a57b3e Add fix-compatibility npm command 2020-03-04 09:29:05 +08:00
MengYX
753647fd4d Update Dependencies 2020-03-04 08:37:54 +08:00
MengYX
97f34783ef Update Babel config 2020-03-04 08:37:39 +08:00
MengYX
7ba053cc07 Add temporary solution to fix compatibility for Edge 18 2020-03-04 08:37:16 +08:00
MengYX
a1fb6bc00a Merge branch 'pull/22' 2020-03-03 20:01:42 +08:00
MengYX
de28e844c2 Optimize loader tips for outdated browser 2020-03-03 20:00:44 +08:00
任宝硕
59266f7625 Update index.html 2020-03-03 16:41:45 +08:00
MengYX
1a93da738c Reformat Code 2020-03-01 23:36:16 +08:00
MengYX
ea78532e53 Fix Update Check 2020-03-01 23:34:24 +08:00
任宝硕
3f36619be1 Update index.html 2020-03-01 16:16:34 +08:00
任宝硕
23ada91260 对部分老旧浏览器进行显示调整 + 部分内容修复 2020-03-01 16:14:43 +08:00
MengYX
c15c600cee #20 Add Support For Netease DJ Files 2020-02-29 19:52:41 +08:00
MengYX
d121d38e0d Bump Version 2020-02-23 13:52:03 +08:00
MengYX
166086ae03 #19 Add Download Type: Origin Filename 2020-02-23 13:45:30 +08:00
MengYX
4755674c98 Add Update Check 2020-02-23 13:44:19 +08:00
MengYX
f89202ee62 Immediately Load Latest App 2020-02-23 13:42:50 +08:00
MengYX
b5f8c4a237 Update Dependencies 2020-02-23 13:42:20 +08:00
MengYX
a137af42ec Optimize UI 2020-02-23 13:41:39 +08:00
MengYX
0441f8670a Fix Decrypt Mflac Error 2020-02-12 23:04:48 +08:00
MengYX
07c51bd62d Fix Decrypt Algorithm Error 2020-02-11 17:03:46 +08:00
MengYX
ecbb5b5042 Optimize Loading 2020-02-11 16:35:45 +08:00
MengYX
bc3d7f53aa Fix Mgg Mask Detect Bug 2020-02-11 16:23:50 +08:00
MengYX
354da563b3 Update README and Bump Version 2020-02-11 16:00:34 +08:00
MengYX
9cde491254 Add Detect Media Type by File 2020-02-11 15:52:22 +08:00
MengYX
5171872ec9 Optimize Import 2020-02-11 14:48:27 +08:00
MengYX
08553884ab Use Universal Decoder for Qmc,Mgg,Mflac 2020-02-11 14:35:17 +08:00
MengYX
61622cf7ed Use Universal Mask for Qmc,Mgg,Mflac
Add Local Experimental Support For Mgg
2020-02-11 00:34:26 +08:00
MengYX
4187d433d6 Add Experimental Support For Mgg 2020-02-10 20:10:48 +08:00
MengYX
30853a5617 Bump Version 2020-02-09 15:16:23 +08:00
MengYX
e6cba313c2 Better Way to Detect Mflac Mask 2020-02-09 14:05:40 +08:00
MengYX
34df70ba70 Adjust for Debugging 2020-02-07 20:17:45 +08:00
MengYX
b4be250585 Fix Babel Config 2020-02-07 14:41:21 +08:00
MengYX
b0d8b3c8d2 Remove Useless Information 2020-02-06 16:18:40 +08:00
MengYX
d0b13871f7 Split App.vue 2020-02-06 16:01:35 +08:00
MengYX
e81c3d82e5 Optimize Bundle Size 2020-02-06 14:05:46 +08:00
MengYX
8680be846f Merge branch 'ix-master' 2020-02-05 02:08:08 +08:00
MengYX
285277f303 Bump Version 2020-02-05 01:58:53 +08:00
MengYX
5dd1f3bd9d Add instant download to avoid memory occupation 2020-02-05 01:53:58 +08:00
MengYX
b1fa5612e9 Merge branch 'pull/17'
# Conflicts:
#	src/App.vue
2020-02-05 01:38:34 +08:00
MengYX
f98852a7a0 Add Web Worker 2020-02-05 00:30:44 +08:00
MengYX
f71729d933 Merge pull request #16 from smtop/dev
增加歌曲命名格式选项
2020-02-04 19:41:01 +08:00
1519715742@qq.com
adb44fe8c9 Performance improvement in multiple files 2020-02-04 19:12:44 +08:00
smdev
145e1a6ede 增加歌曲命名格式选项 2020-02-04 18:24:53 +08:00
MengYX
c5b2a8357c Update CI 2020-02-01 12:05:00 +08:00
MengYX
7556c39c71 Edit index.html Upgrade Dependencies 2020-01-31 11:47:16 +08:00
MengYX
304ad63585 #9 Add QQ Music tkm Format 2020-01-27 18:02:39 +08:00
MengYX
77659e0427 Fix QMC filename error 2020-01-27 16:43:40 +08:00
MengYX
7ed3ee8fb0 #11 Add Moo Music Format 2020-01-27 14:06:45 +08:00
MengYX
589697068d Merge branch 'master' of github.com:ix64/unlock-music 2020-01-21 20:00:20 +08:00
MengYX
6918ed257e Update CI 2020-01-21 19:52:45 +08:00
MengYX
6a7af6f5f2 Update Readme [CI SKIP] 2020-01-21 19:31:30 +08:00
MengYX
a0d31d880e Add Support For: tm0/2/3/6 2020-01-21 19:21:17 +08:00
MengYX
8267148e96 Reconstruct 2020-01-21 19:20:46 +08:00
MengYX
e108f7c016 Update Deps 2020-01-21 19:01:52 +08:00
MengYX
7cbf860948 Update README.md 2019-12-23 19:09:24 +08:00
MengYX
8efb1d7d45 Update README.md 2019-12-16 19:01:56 +08:00
MengYX
ba66e38968 Revert: Favicon 2019-12-08 19:12:15 +08:00
MengYX
995b88ff5a Upgrade Vue Cli 2019-12-07 15:09:09 +08:00
MengYX
00de3888ff Remove unused icon 2019-12-07 14:44:32 +08:00
MengYX
4ec1847682 Change build mode 2019-12-07 14:32:28 +08:00
MengYX
1ede0f3193 Fix accept in uploader 2019-12-07 12:23:18 +08:00
MengYX
66a247be3a Use Post 2019-12-01 22:52:29 +08:00
MengYX
55ff80f59b Fix link error in README [SKIP CI] 2019-12-01 21:04:51 +08:00
MengYX
cf078a4fa6 Update README and Dependencies 2019-11-30 21:10:40 +08:00
MengYX
aee06b383f Change Notification 2019-11-24 19:33:11 +08:00
MengYX
1ba6d93fb2 Report Error Type 2019-11-23 19:22:32 +08:00
MengYX
ad3e2d55fc Fix No Status Error 2019-11-23 19:11:40 +08:00
MengYX
fef9841cb4 Fix Download Button 2019-11-23 18:56:45 +08:00
MengYX
ba2f717842 Show Detail Info While Error Occurred 2019-11-23 18:30:59 +08:00
MengYX
e8bee61533 Update Dependencies 2019-11-23 18:14:15 +08:00
MengYX
37c6c5554b Add Partial Support For .mflac 2019-11-23 18:09:33 +08:00
MengYX
093145eb99 Reformat Code [SKIP CI] 2019-11-23 15:10:08 +08:00
MengYX
c1f029705b Add tips for qmcogg 2019-11-23 15:03:45 +08:00
MengYX
d7a2f9361e CI: Auto Deploy and Use Cache 2019-11-10 22:38:43 +08:00
MengYX
62cf8663d8 Add CI 2019-11-10 17:25:01 +08:00
MengYX
831f578daa Use new window to open link 2019-11-10 01:30:11 +08:00
MengYX
03a3e7ef90 Update Dependencies 2019-11-10 01:16:08 +08:00
MengYX
9e9b2ec7f3 Add .qmcogg (without test)
Add Tips
2019-11-10 00:59:13 +08:00
MengYX
3a50460c61 Fix Display Bugs In Edge and Safari 2019-09-21 23:32:21 +08:00
MengYX
83f4015695 Enhanced ease of use 2019-09-18 00:21:18 +08:00
MengYX
3e3a98142b Add Analytics
Fix an error statement
2019-09-14 20:48:57 +08:00
MengYX
91f91ce0c9 Complete ID3 for ncm 2019-09-12 15:51:10 +08:00
MengYX
5c18124ecd Fix an icon error 2019-09-08 15:06:08 +08:00
MengYX
d54a1ebedb Merge pull request #2 from ix64/pull/1
Fix bugs after using music-metadata-browser
2019-09-08 14:50:39 +08:00
MengYX
1fb762630f Merge branch 'master' into pull/1 2019-09-08 14:49:03 +08:00
MengYX
bb9227aa9e Merge pull request #1 from Borewit/music-metadata-browser
Maybe try music-metadata-browser?
2019-09-08 14:45:23 +08:00
Borewit
1bcf198c7b Fix parsing picture in metadata 2019-09-08 08:28:25 +02:00
MengYX
d6e31becc7 Fix bugs after using music-metadata-browser 2019-09-08 13:40:32 +08:00
Borewit
06e04f85b6 use music-metadata-browser 2019-09-07 19:50:04 +02:00
MengYX
b893dd47cf Change icon 2019-08-25 15:55:36 +08:00
MengYX
8011a07342 Downgrade jsmediatag to avoid bug
Rename project
2019-08-25 15:16:20 +08:00
MengYX
087547a7e5 Update page footer 2019-08-25 14:44:27 +08:00
MengYX
d3d8d145ba Update readme 2019-08-25 14:31:38 +08:00
MengYX
ed4d83e19d Update readme 2019-08-25 14:27:11 +08:00
MengYX
e28e6c3654 Merge branch 'master' of https://git.ixarea.com/MusicCrack/music-crack 2019-08-25 14:12:48 +08:00
MengYX
af6d3a9e2e Update dependencies to fix CVE-2019-10744 2019-08-25 14:10:33 +08:00
91 changed files with 29644 additions and 5314 deletions

View File

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

View File

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

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
.DS_Store
node_modules
/dist
/coverage
# local env files
.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
Copyright (c) 2019-2020 MengYX
Copyright (c) 2019-2021 MengYX
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,42 +1,48 @@
# Unlock Music 音乐解锁
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- unlock-music项目是以学习和技术研究的初衷创建的修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE)
- Unlock MusicCLI版本正在开发中
- 我们新建了Telegram群组,欢迎加入![https://t.me/unlock_music_chat](https://t.me/unlock_music_chat)
- [CLI版本 Alpha](https://github.com/unlock-music/cli) 大批量转换建议使用CLI版本
- [相关的其他项目](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)
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循 [License][license]
- Unlock MusicCLI 版本可以在 [unlock-music/cli][repo_cli] 找到,大批量转换建议使用 CLI 版本
- 我们新建了 Telegram 群组 [`@unlock_music_chat`][tg_group] ,欢迎加入!
- [相关的其他项目][related_projects]
![Test Build](https://github.com/ix64/unlock-music/workflows/Test%20Build/badge.svg)
![GitHub releases](https://img.shields.io/github/downloads/ix64/unlock-music/total)
![Test Build](https://github.com/unlock-music/unlock-music/workflows/Test%20Build/badge.svg)
![GitHub releases](https://img.shields.io/github/downloads/unlock-music/unlock-music/total)
![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] 写入封面图片
- [x] Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
- [x] Moo 音乐格式 (.bkcmp3/.bkcflac/...)
- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6)
- [x] QQ音乐新格式 (实验性支持)
- [x] .mflac
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
- [x] 网易云音乐格式 (.ncm)
- [x] 补全ncm的ID3/FlacMeta信息
- [x] 虾米音乐格式 (.xm) (测试阶段)
- [x] 酷我音乐格式 (.kwm) (测试阶段)
- [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))
- [x] QQ 音乐新格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
- [x] <ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)
- [x] 虾米音乐格式 (.xm)
- [x] 酷我音乐格式 (.kwm)
- [x] 酷狗音乐格式 (.kgm/.vpr) ([CLI 版本][kgm_cli])
[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] 渐进式Web应用
- [x] 渐进式 Web 应用 (PWA)
- [x] 多线程
- [x] 写入Meta和封面图片
## 使用方法
@@ -48,8 +54,8 @@
### 使用已构建版本
- 从[GitHub Release](https://github.com/ix64/unlock-music/releases/latest)下载已构建的版本
- 本地使用请下载`legacy版本``modern版本`只能通过**http/https协议**访问)
- 从[GitHub Release](https://github.com/unlock-music/unlock-music/releases/latest)下载已构建的版本
- 本地使用请下载`legacy版本``modern版本`只能通过 **http(s)协议** 访问)
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**
### 使用 Docker 镜像
@@ -61,11 +67,25 @@ docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
### 自行构建
- 环境要求
- nodejs
- nodejs (v16.x)
- npm
1. 获取项目源代码后执行 `npm install` 安装相关依赖
2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录
1. 获取项目源代码后安装相关依赖
- `npm run serve` 可用于开发
3. 如需构建浏览器扩展build完成后还需要执行`npm run make-extension`
```sh
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 = {
presets: [
'@vue/app'
'@vue/app',
'@babel/preset-typescript'
],
plugins: [
["component", {

View File

@@ -6,6 +6,7 @@
"128": "./img/icons/msapplication-icon-144x144.png"
},
"description": "在任何设备上解锁已购的加密音乐!",
"permissions": ["storage"],
"offline_enabled": true,
"options_page": "./index.html",
"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,6 +15,11 @@ const manifest = JSON.parse(manifestRaw)
const pkgRaw = fs.readFileSync("./package.json", "utf-8")
const pkg = JSON.parse(pkgRaw)
manifest["version"] = pkg["version"]
verExt = pkg["version"]
if (verExt.startsWith("v")) verExt = verExt.slice(1)
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")
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",
"version": "v1.9.0",
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
"version": "v1.10.0",
"ext_build": 0,
"updateInfo": "重写QMC解锁完全支持.mflac*/.mgg*; 支持JOOX解锁",
"license": "MIT",
"description": "Unlock encrypted music file in browser.",
"repository": {
@@ -10,12 +11,18 @@
},
"private": true,
"scripts": {
"postinstall": "patch-package",
"serve": "vue-cli-service serve",
"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"
},
"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",
"browser-id3-writer": "^4.4.0",
"core-js": "^3.16.0",
@@ -24,23 +31,28 @@
"iconv-lite": "^0.6.3",
"jimp": "^0.16.1",
"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",
"threads": "^1.6.5",
"vue": "^2.6.14"
},
"devDependencies": {
"@types/crypto-js": "^4.0.2",
"@types/jest": "^27.0.3",
"@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-pwa": "^4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13",
"@vue/cli-service": "^4.5.13",
"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",
"semver": "^7.3.5",
"threads-plugin": "^1.4.0",
"typescript": "~4.1.6",
"typescript": "^4.5.4",
"vue-cli-plugin-element": "^1.0.1",
"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

@@ -6,7 +6,7 @@
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<title>音乐解锁</title>
<meta content="音乐,解锁,ncm,qmc,mgg,mflac,qq音乐,网易云音乐,加密" name="keywords"/>
<meta content="音乐,解锁,qmc,mgg,mflac,qq音乐,加密" name="keywords"/>
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description"/>
<script src="./ixarea-stats.js"></script>
<!--@formatter:off-->

View File

@@ -10,12 +10,12 @@
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</el-row>
<el-row>
目前支持网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
目前支持 QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>
</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>
开放源代码
@@ -25,46 +25,48 @@
</template>
<script>
import FileSelector from "@/component/FileSelector"
import PreviewTable from "@/component/PreviewTable"
import config from "@/../package.json"
import Home from "@/view/Home";
import {checkUpdate} from "@/utils/api";
import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable';
import config from '@/../package.json';
import Home from '@/view/Home';
import { checkUpdate } from '@/utils/api';
export default {
name: 'app',
components: {
FileSelector,
PreviewTable,
Home
Home,
},
data() {
return {
version: config.version,
}
};
},
created() {
this.$nextTick(() => this.finishLoad());
},
methods: {
async finishLoad() {
const mask = document.getElementById("loader-mask");
const mask = document.getElementById('loader-mask');
if (!!mask) mask.remove();
let updateInfo;
try {
updateInfo = await checkUpdate(this.version)
updateInfo = await checkUpdate(this.version);
} catch (e) {
console.warn("check version info failed", e)
console.warn('check version info failed', e);
}
if ((updateInfo && process.env.NODE_ENV === 'production') && (updateInfo.HttpsFound ||
(updateInfo.Found && window.location.protocol !== "https:"))) {
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'
position: 'top-left',
});
} else {
this.$notify.info({
@@ -72,14 +74,14 @@ export default {
message: `我们使用PWA技术无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left'
position: 'top-left',
});
}
}
},
}
},
};
</script>
<style lang="scss">
@import "scss/unlock-music";
@import 'scss/unlock-music';
</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,25 +1,17 @@
<template>
<el-upload
:auto-upload="false"
:on-change="addFile"
:show-file-list="false"
action=""
drag
multiple>
<el-upload :auto-upload="false" :on-change="addFile" :show-file-list="false" action="" drag multiple>
<i class="el-icon-upload" />
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<div slot="tip" class="el-upload__tip">
<div>
仅在浏览器内对文件进行解锁无需消耗流量
<el-tooltip effect="dark" placement="top-start">
<div slot="content">
算法在源代码中已经提供所有运算都发生在本地
</div>
<div slot="content">算法在源代码中已经提供所有运算都发生在本地</div>
<i class="el-icon-info" style="font-size: 12px" />
</el-tooltip>
</div>
<div>
工作模式: {{ parallel ? "多线程 Worker" : "单线程 Queue" }}
工作模式: {{ parallel ? '多线程 Worker' : '单线程 Queue' }}
<el-tooltip effect="dark" placement="top-start">
<div slot="content">
将此工具部署在HTTPS环境下可以启用Web Worker特性<br />
@@ -29,10 +21,14 @@
</el-tooltip>
</div>
</div>
<transition name="el-fade-in"><!--todo: add delay to animation-->
<transition name="el-fade-in"
><!--todo: add delay to animation-->
<el-progress
v-show="progress_show" :format="progress_string" :percentage="progress_value"
:stroke-width="16" :text-inside="true"
v-show="progress_show"
:format="progress_string"
:percentage="progress_value"
:stroke-width="16"
:text-inside="true"
style="margin: 16px 6px 0 6px"
></el-progress>
</transition>
@@ -40,60 +36,56 @@
</template>
<script>
import {spawn, Worker, Pool} from "threads"
import {CommonDecrypt} from "@/decrypt/common.ts";
import {DecryptQueue} from "@/utils/utils";
import { spawn, Worker, Pool } from 'threads';
import { Decrypt } from '@/decrypt';
import { DecryptQueue } from '@/utils/utils';
import { storage } from '@/utils/storage';
export default {
name: "FileSelector",
name: 'FileSelector',
data() {
return {
task_all: 0,
task_finished: 0,
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
return this.task_all ? (this.task_finished / this.task_all) * 100 : 0;
},
progress_show() {
return this.task_all !== this.task_finished
}
return this.task_all !== this.task_finished;
},
},
mounted() {
if (window.Worker && window.location.protocol !== "file:" && process.env.NODE_ENV === 'production') {
console.log("Using Worker Pool")
this.queue = Pool(
() => spawn(new Worker('@/utils/worker.ts')),
navigator.hardwareConcurrency || 1
)
this.parallel = true
if (window.Worker && window.location.protocol !== 'file:' && process.env.NODE_ENV === 'production') {
console.log('Using Worker Pool');
this.queue = Pool(() => spawn(new Worker('@/utils/worker.ts')), navigator.hardwareConcurrency || 1);
this.parallel = true;
} else {
console.log("Using Queue in Main Thread")
console.log('Using Queue in Main Thread');
}
},
methods: {
progress_string() {
return `${this.task_finished} / ${this.task_all}`
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)
this.task_all++;
this.queue.queue(async (dec = Decrypt) => {
console.log('start handling', file.name);
try {
this.$emit("success", await dec(file));
this.$emit('success', await dec(file, await storage.getAll()));
} catch (e) {
console.error(e)
this.$emit("error", e, file.name)
console.error(e);
this.$emit('error', e, file.name);
} finally {
this.task_finished++
this.task_finished++;
}
})
});
},
}
}
},
};
</script>

View File

@@ -1,12 +1,9 @@
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column label="封面">
<template slot-scope="scope">
<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">暂无封面</div>
</el-image>
</template>
</el-table-column>
@@ -27,14 +24,10 @@
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button circle
icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
</el-button>
<el-button circle
icon="el-icon-download" @click="handleDownload(scope.row)">
</el-button>
<el-button circle
icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button>
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
</el-button>
</template>
</el-table-column>
@@ -42,30 +35,28 @@
</template>
<script>
import {RemoveBlobMusic} from '@/utils/utils'
import { RemoveBlobMusic } from '@/utils/utils';
export default {
name: "PreviewTable",
name: 'PreviewTable',
props: {
tableData: { type: Array, required: true },
policy: {type: Number, required: true}
policy: { type: Number, required: true },
},
methods: {
handlePlay(index, row) {
this.$emit("play", row.file);
this.$emit('play', row.file);
},
handleDelete(index, row) {
RemoveBlobMusic(row);
this.tableData.splice(index, 1);
},
handleDownload(row) {
this.$emit("download", row)
this.$emit('download', row);
},
}
}
},
};
</script>
<style scoped>
</style>
<style scoped></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 {
title: string
album?: string
artist?: string
title: string;
album?: string;
artist?: string;
mime: string
ext: string
mime: string;
ext: string;
file: string
blob: Blob
picture?: string
message?: string
rawExt?: string
rawFilename?: string
file: string;
blob: Blob;
picture?: string;
message?: string;
rawExt?: string;
rawFilename?: string;
}
export interface FileInfo {
status: string
name: string,
size: number,
percentage: number,
uid: number,
raw: File
status: string;
name: string;
size: number;
percentage: number;
uid: number;
raw: File;
}

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

@@ -0,0 +1,90 @@
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 '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

@@ -4,64 +4,68 @@ import {
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt
} from "@/decrypt/utils.ts";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";
import config from "@/../package.json"
SniffAudioExt,
} from '@/decrypt/utils';
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import { DecryptResult } from '@/decrypt/entity';
import config from '@/../package.json';
//prettier-ignore
const VprHeader = [
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 = [
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 = [
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
0x00]
0x00
]
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!")
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!")
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 bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer);
let headerLen = bHeaderLen.getUint32(0, true);
let audioData = oriData.slice(headerLen)
let dataLen = audioData.length
let audioData = oriData.slice(headerLen);
let dataLen = audioData.length;
if (audioData.byteLength > 1 << 26) {
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁");
}
let key1 = new Uint8Array(17)
key1.set(oriData.slice(0x1c, 0x2c), 0)
let key1 = new Uint8Array(17);
key1.set(oriData.slice(0x1c, 0x2c), 0);
if (MaskV2.length === 0) {
if (!await LoadMaskV2()) throw Error("加载Kgm/Vpr Mask数据失败")
if (!(await LoadMaskV2())) throw Error('加载Kgm/Vpr Mask数据失败');
}
for (let i = 0; i < dataLen; i++) {
let med8 = key1[i % 17] ^ audioData[i]
med8 ^= (med8 & 0xf) << 4
let med8 = key1[i % 17] ^ audioData[i];
med8 ^= (med8 & 0xf) << 4;
let msk8 = GetMask(i)
msk8 ^= (msk8 & 0xf) << 4
audioData[i] = med8 ^ msk8
let msk8 = GetMask(i);
msk8 ^= (msk8 & 0xf) << 4;
audioData[i] = med8 ^ msk8;
}
if (raw_ext === "vpr") {
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17]
if (raw_ext === 'vpr') {
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17];
}
const ext = SniffAudioExt(audioData);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime });
const musicMeta = await metaParseBlob(musicBlob);
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
return {
album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta),
@@ -70,53 +74,71 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
ext,
mime,
title,
artist
artist,
};
}
}
function GetMask(pos: number) {
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4]
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4];
}
let MaskV2: Uint8Array = new Uint8Array(0);
async function LoadMaskV2(): Promise<boolean> {
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 (!!self.document) {// using Web Worker
mask_url = "./static/kgm.mask"
} else {// using Main thread
mask_url = "../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 (!!self.document) {
// using Web Worker
mask_url = './static/kgm.mask';
} else {
// using Main thread
mask_url = '../static/kgm.mask';
}
}
try {
const resp = await fetch(mask_url, {method: "GET"})
const resp = await fetch(mask_url, { method: 'GET' });
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true
return true;
} catch (e) {
console.error(e)
return false
console.error(e);
return false;
}
}
//prettier-ignore
const MaskV2PreDef = [
0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37,
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68,
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A,
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B,
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7,
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48,
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B,
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84,
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,
]
0xb8, 0xd5, 0x3d, 0xb2, 0xe9, 0xaf, 0x78, 0x8c,
0x83, 0x33, 0x71, 0x51, 0x76, 0xa0, 0xcd, 0x37,
0x2f, 0x3e, 0x35, 0x8d, 0xa9, 0xbe, 0x98, 0xb7,
0xe7, 0x8c, 0x22, 0xce, 0x5a, 0x61, 0xdf, 0x68,
0x69, 0x89, 0xfe, 0xa5, 0xb6, 0xde, 0xa9, 0x77,
0xfc, 0xc8, 0xbd, 0xbd, 0xe5, 0x6d, 0x3e, 0x5a,
0x36, 0xef, 0x69, 0x4e, 0xbe, 0xe1, 0xe9, 0x66,
0x1c, 0xf3, 0xd9, 0x02, 0xb6, 0xf2, 0x12, 0x9b,
0x44, 0xd0, 0x6f, 0xb9, 0x35, 0x89, 0xb6, 0x46,
0x6d, 0x73, 0x82, 0x06, 0x69, 0xc1, 0xed, 0xd7,
0x85, 0xc2, 0x30, 0xdf, 0xa2, 0x62, 0xbe, 0x79,
0x2d, 0x62, 0x62, 0x3d, 0x0d, 0x7e, 0xbe, 0x48,
0x89, 0x23, 0x02, 0xa0, 0xe4, 0xd5, 0x75, 0x51,
0x32, 0x02, 0x53, 0xfd, 0x16, 0x3a, 0x21, 0x3b,
0x16, 0x0f, 0xc3, 0xb2, 0xbb, 0xb3, 0xe2, 0xba,
0x3a, 0x3d, 0x13, 0xec, 0xf6, 0x01, 0x45, 0x84,
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

@@ -4,42 +4,41 @@ import {
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt
} from "@/decrypt/utils.ts";
import {Decrypt as RawDecrypt} from "@/decrypt/raw.ts";
SniffAudioExt,
} from '@/decrypt/utils';
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import { DecryptResult } from '@/decrypt/entity';
//prettier-ignore
const MagicHeader = [
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
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> {
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (!BytesHasPrefix(oriData, MagicHeader)) {
if (SniffAudioExt(oriData) === "aac") {
return await RawDecrypt(file, raw_filename, "aac", false)
if (SniffAudioExt(oriData) === 'aac') {
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 mask = createMaskFromKey(fileKey)
let fileKey = oriData.slice(0x18, 0x20);
let mask = createMaskFromKey(fileKey);
let audioData = oriData.slice(0x400);
let lenAudioData = audioData.length;
for (let cur = 0; cur < lenAudioData; ++cur)
audioData[cur] ^= mask[cur % 0x20];
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20];
const ext = SniffAudioExt(audioData);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime });
const musicMeta = await metaParseBlob(musicBlob);
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
return {
album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta),
@@ -48,30 +47,28 @@ export async function Decrypt(file: File, raw_filename: string, _: string): Prom
mime,
title,
artist,
ext
ext,
};
}
}
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
let keyView = new DataView(keyBytes.buffer)
let keyStr = keyView.getBigUint64(0, true).toString()
let keyStrTrim = trimKey(keyStr)
let key = new Uint8Array(32)
let keyView = new DataView(keyBytes.buffer);
let keyStr = keyView.getBigUint64(0, true).toString();
let keyStrTrim = trimKey(keyStr);
let key = new Uint8Array(32);
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 {
let lenRaw = keyRaw.length;
let out = keyRaw;
if (lenRaw > 32) {
out = keyRaw.slice(0, 32)
out = keyRaw.slice(0, 32);
} else if (lenRaw < 32) {
out = keyRaw.padEnd(32, keyRaw)
out = keyRaw.padEnd(32, keyRaw);
}
return out
return out;
}

View File

@@ -1,243 +0,0 @@
import {
AudioMimeType,
BytesHasPrefix,
GetArrayBuffer,
GetImageFromURL,
GetMetaFromFile, IMusicMeta,
SniffAudioExt,
WriteMetaToFlac,
WriteMetaToMp3
} from "@/decrypt/utils.ts";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import jimp from 'jimp';
import AES from "crypto-js/aes";
import PKCS7 from "crypto-js/pad-pkcs7";
import ModeECB from "crypto-js/mode-ecb";
import WordArray from "crypto-js/lib-typedarrays";
import Base64 from "crypto-js/enc-base64";
import EncUTF8 from "crypto-js/enc-utf8";
import EncHex from "crypto-js/enc-hex";
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];
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
return (new NcmDecrypt(await GetArrayBuffer(file), raw_filename)).decrypt()
}
interface NcmMusicMeta {
//musicId: number
musicName?: string
artist?: Array<string | number>[]
format?: string
album?: string
albumPic?: string
}
interface NcmDjMeta {
mainMusic: NcmMusicMeta
}
class NcmDecrypt {
raw: ArrayBuffer
view: DataView
offset: number = 0
filename: string
format: string = ""
mime: string = ""
audio?: Uint8Array
blob?: Blob
oriMeta?: NcmMusicMeta
newMeta?: IMusicMeta
image?: { mime: string, buffer: ArrayBuffer, url: string }
constructor(buf: ArrayBuffer, filename: string) {
const prefix = new Uint8Array(buf, 0, 8)
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error("此ncm文件已损坏")
this.offset = 10
this.raw = buf
this.view = new DataView(buf)
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;
}
return result.slice(17)
}
_getKeyBox(): Uint8Array {
const keyData = this._getKeyData()
const box = new Uint8Array(Array(256).keys());
const keyDataLen = keyData.length;
let j = 0;
for (let i = 0; i < 256; i++) {
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
[box[i], box[j]] = [box[j], box[i]];
}
return box.map((_, i, arr) => {
i = (i + 1) & 0xff;
const si = arr[i];
const sj = arr[(i + si) & 0xff];
return arr[(si + sj) & 0xff];
});
}
_getMetaData(): NcmMusicMeta {
const metaDataLen = this.view.getUint32(this.offset, true);
this.offset += 4;
if (metaDataLen === 0) return {};
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 {
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]));
}
if (artists.length === 0 && !!info.artist) {
artists = info.artist.split(',')
.map(val => val.trim()).filter(val => val != "");
}
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}
}
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 +0,0 @@
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts";
import {DecryptResult} from "@/decrypt/entity";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
: Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let length = buffer.length
for (let i = 0; i < length; i++) {
buffer[i] ^= 163
}
const 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 {
title,
artist,
ext,
album: tag.common.album,
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file),
blob: file,
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 {toByteArray as Base64Decode} from 'base64-js'
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
SniffAudioExt, WriteMetaToFlac, WriteMetaToMp3
} from "@/decrypt/utils.ts";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
import iconv from "iconv-lite";
import {DecryptResult} from "@/decrypt/entity";
import {queryAlbumCover, queryKeyInfo, reportKeyUsage} from "@/utils/api";
import { DecryptResult } from '@/decrypt/entity';
import { QmcDeriveKey } from '@/decrypt/qmc_key';
import { DecryptQMCWasm } from '@/decrypt/qmc_wasm';
import { extractQQMusicMeta } from '@/utils/qm_meta';
interface Handler {
ext: string
detect: boolean
handler(data?: Uint8Array): QmcMask | undefined
ext: string;
version: number;
}
export const HandlerMap: { [key: string]: Handler } = {
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
"mgg.cache": {handler: QmcMaskDetectMgg, ext: "ogg", detect: false},
"mflac.cache": {handler: QmcMaskDetectMflac, ext: "flac", detect: false},
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
mgg: { ext: 'ogg', version: 2 },
mgg1: { ext: 'ogg', version: 2 },
mflac: { ext: 'flac', version: 2 },
mflac0: { ext: 'flac', version: 2 },
// qmcflac / qmcogg:
// 有可能是 v2 加密但混用同一个后缀名。
qmcflac: { ext: 'flac', version: 2 },
qmcogg: { ext: 'ogg', version: 2 },
qmc0: { ext: 'mp3', version: 1 },
qmc2: { ext: 'ogg', version: 1 },
qmc3: { ext: 'mp3', version: 1 },
bkcmp3: { ext: 'mp3', version: 1 },
bkcflac: { ext: 'flac', version: 1 },
tkm: { ext: 'm4a', version: 1 },
'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> {
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
const handler = HandlerMap[raw_ext];
let { version } = handler;
const fileData = new Uint8Array(await GetArrayBuffer(file));
let audioData, seed, keyData;
if (handler.detect) {
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
const keyPos = fileData.length - 4 - keyLen;
audioData = fileData.slice(0, keyPos);
seed = handler.handler(audioData);
keyData = fileData.slice(keyPos, keyPos + keyLen);
if (!seed) seed = await queryKey(keyData, raw_filename, raw_ext);
if (!seed) throw raw_ext + "格式仅提供实验性支持";
const fileBuffer = await GetArrayBuffer(file);
let musicDecoded: Uint8Array | undefined;
let musicID: number | string | undefined;
if (version === 2 && globalThis.WebAssembly) {
console.log('qmc: using wasm decoder');
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
// 若 v2 检测失败,降级到 v1 再尝试一次
if (v2Decrypted.success) {
musicDecoded = v2Decrypted.data;
musicID = v2Decrypted.songId;
} else {
audioData = fileData;
seed = handler.handler(audioData) as QmcMask;
if (!seed) throw raw_ext + "格式仅提供实验性支持";
console.warn('qmc2-wasm failed with error %s', v2Decrypted.error || '(no error)');
}
}
if (!musicDecoded) {
// may throw error
console.log('qmc: using js decoder');
const d = new QmcDecoder(new Uint8Array(fileBuffer));
musicDecoded = d.decrypt();
musicID = d.songID;
}
let musicDecoded = seed.Decrypt(audioData);
const ext = SniffAudioExt(musicDecoded, handler.ext);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([musicDecoded], {type: mime});
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
new Blob([musicDecoded], { type: mime }),
raw_filename,
ext,
musicID,
);
const musicMeta = await metaParseBlob(musicBlob);
for (let metaIdx in musicMeta.native) {
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
console.warn("try using gbk encoding to decode meta")
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
}
}
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
if (keyData) reportKeyUsage(keyData, seed.getMatrix128(),
raw_filename, raw_ext, info.title, info.artist, musicMeta.common.album).then().catch();
let imgUrl = GetCoverFromFile(musicMeta);
if (!imgUrl) {
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
if (imgUrl) {
const imageInfo = await GetImageFromURL(imgUrl);
if (imageInfo) {
imgUrl = imageInfo.url
try {
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
if (ext === "mp3") {
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else if (ext === 'flac') {
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else {
console.info("writing metadata for " + ext + " is not being supported for now")
}
} catch (e) {
console.warn("Error while appending cover image to file " + e)
}
}
}
}
return {
title: info.title,
artist: info.artist,
title: title,
artist: artist,
ext: ext,
album: musicMeta.common.album,
album: album,
picture: imgUrl,
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime: mime
file: URL.createObjectURL(blob),
blob: blob,
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;
public constructor(file: Uint8Array) {
this.file = file;
this.size = file.length;
this.searchKey();
}
private _songID?: number;
public get songID() {
return this._songID;
}
public decrypt(): Uint8Array {
if (!this.cipher) {
throw new Error('no cipher found');
}
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();
}
}
}
async function queryKey(keyData: Uint8Array, filename: string, format: string): Promise<QmcMask | undefined> {
try {
const data = await queryKeyInfo(keyData, filename, format)
return new QmcMask(Base64Decode(data.Matrix44));
} catch (e) {
console.warn(e);
private setCipher(keyRaw: Uint8Array) {
const keyDec = QmcDeriveKey(keyRaw);
if (keyDec.length > 300) {
this.cipher = new QmcRC4Cipher(keyDec);
} else {
this.cipher = new QmcMapCipher(keyDec);
}
}
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
try {
const data = await queryAlbumCover(title, artist, album)
return `${song_query_url}/${data.Type}/${data.Id}`
} catch (e) {
console.warn(e);
}
return ""
}

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

@@ -4,39 +4,38 @@ import {
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt,
SplitFilename
} from "@/decrypt/utils.ts";
SplitFilename,
} 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)
: Promise<DecryptResult> {
export async function Decrypt(file: Blob, raw_filename: string, _: string): Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let length = buffer.length
let length = buffer.length;
for (let i = 0; i < length; i++) {
buffer[i] ^= 0xf4
buffer[i] ^= 0xf4;
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] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
}
let ext = SniffAudioExt(buffer, "");
const newName = SplitFilename(raw_filename)
let audioBlob: Blob
if (ext !== "" || newName.ext === "mp3") {
audioBlob = new Blob([buffer], {type: AudioMimeType[ext]})
let ext = SniffAudioExt(buffer, '');
const newName = SplitFilename(raw_filename);
let audioBlob: Blob;
if (ext !== '' || newName.ext === 'mp3') {
audioBlob = new Blob([buffer], { type: AudioMimeType[ext] });
} 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);
} else {
throw "不支持的QQ音乐缓存格式"
throw '不支持的QQ音乐缓存格式';
}
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 {
title,
@@ -46,6 +45,6 @@ export async function Decrypt(file: Blob, raw_filename: string, _: string)
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(audioBlob),
blob: audioBlob,
mime: AudioMimeType[ext]
}
mime: AudioMimeType[ext],
};
}

View File

@@ -1,19 +1,23 @@
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)
: Promise<DecryptResult> {
export async function Decrypt(
file: Blob,
raw_filename: string,
raw_ext: string,
detect: boolean = true,
): Promise<DecryptResult> {
let ext = raw_ext;
if (detect) {
const buffer = new Uint8Array(await GetArrayBuffer(file));
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 {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist);
return {
title,
@@ -23,6 +27,6 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string,
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file),
blob: file,
mime: AudioMimeType[ext]
}
mime: AudioMimeType[ext],
};
}

View File

@@ -1,6 +1,6 @@
import {Decrypt as RawDecrypt} from "./raw";
import {GetArrayBuffer} from "@/decrypt/utils.ts";
import {DecryptResult} from "@/decrypt/entity";
import { Decrypt as RawDecrypt } from './raw';
import { GetArrayBuffer } from '@/decrypt/utils';
import { DecryptResult } from '@/decrypt/entity';
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
@@ -9,6 +9,6 @@ export async function Decrypt(file: File, raw_filename: string): Promise<Decrypt
for (let cur = 0; cur < 8; ++cur) {
audioData[cur] = TM_HEADER[cur];
}
const musicData = new Blob([audioData], {type: "audio/mp4"});
return await RawDecrypt(musicData, raw_filename, "m4a", false)
const musicData = new Blob([audioData], { type: 'audio/mp4' });
return await RawDecrypt(musicData, raw_filename, 'm4a', false);
}

View File

@@ -1,61 +1,66 @@
import {IAudioMetadata} from "music-metadata-browser";
import ID3Writer from "browser-id3-writer";
import MetaFlac from "metaflac-js";
import { IAudioMetadata } from 'music-metadata-browser';
import ID3Writer from 'browser-id3-writer';
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 OGG_HEADER = [0x4F, 0x67, 0x67, 0x53];
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53];
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
//prettier-ignore
export const WMA_HEADER = [
0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11,
0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C,
]
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
export const AAC_HEADER = [0xFF, 0xF1]
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11,
0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c,
];
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46];
export const AAC_HEADER = [0xff, 0xf1];
export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38];
export const AudioMimeType: { [key: string]: string } = {
mp3: "audio/mpeg",
flac: "audio/flac",
m4a: "audio/mp4",
ogg: "audio/ogg",
wma: "audio/x-ms-wma",
wav: "audio/x-wav",
dff: "audio/x-dff"
mp3: 'audio/mpeg',
flac: 'audio/flac',
m4a: 'audio/mp4',
ogg: 'audio/ogg',
wma: 'audio/x-ms-wma',
wav: 'audio/x-wav',
dff: 'audio/x-dff',
};
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 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 {
if (BytesHasPrefix(data, MP3_HEADER)) return "mp3"
if (BytesHasPrefix(data, FLAC_HEADER)) return "flac"
if (BytesHasPrefix(data, OGG_HEADER)) return "ogg"
if (data.length >= 4 + M4A_HEADER.length &&
BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a"
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = 'mp3'): string {
if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3';
if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac';
if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg';
if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a';
if (BytesHasPrefix(data, WAV_HEADER)) return 'wav';
if (BytesHasPrefix(data, WMA_HEADER)) return 'wma';
if (BytesHasPrefix(data, AAC_HEADER)) return 'aac';
if (BytesHasPrefix(data, DFF_HEADER)) return 'dff';
return fallback_ext;
}
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
if (!!obj.arrayBuffer) return obj.arrayBuffer()
if (!!obj.arrayBuffer) return obj.arrayBuffer();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const rs = e.target?.result
const rs = e.target?.result;
if (!rs) {
reject("read file failed")
reject('read file failed');
} else {
resolve(rs as ArrayBuffer)
resolve(rs as ArrayBuffer);
}
};
reader.readAsArrayBuffer(obj);
@@ -64,22 +69,25 @@ export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
export function GetCoverFromFile(metadata: IAudioMetadata): string {
if (metadata.common?.picture && metadata.common.picture.length > 0) {
return URL.createObjectURL(new Blob(
[metadata.common.picture[0].data],
{type: metadata.common.picture[0].format}
));
return URL.createObjectURL(
new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }),
);
}
return "";
return '';
}
export interface IMusicMetaBasic {
title: string
artist?: string
title: string;
artist?: string;
}
export function GetMetaFromFile(filename: string, exist_title?: string, exist_artist?: string, separator = "-")
: IMusicMetaBasic {
const meta: IMusicMetaBasic = {title: exist_title ?? "", artist: exist_artist}
export function GetMetaFromFile(
filename: string,
exist_title?: string,
exist_artist?: string,
separator = '-',
): IMusicMetaBasic {
const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist };
const items = filename.split(separator);
if (items.length > 1) {
@@ -88,83 +96,83 @@ export function GetMetaFromFile(filename: string, exist_title?: string, exist_ar
} else if (items.length === 1) {
if (!meta.title) meta.title = items[0].trim();
}
return meta
return meta;
}
export async function GetImageFromURL(src: string):
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
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 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}
const url = URL.createObjectURL(new Blob([buffer], { type: mime }));
return { buffer, url, mime };
}
} catch (e) {
console.warn(e)
console.warn(e);
}
}
export interface IMusicMeta {
title: string
artists?: string[]
album?: string
picture?: ArrayBuffer
picture_desc?: string
title: string;
artists?: string[];
album?: string;
picture?: ArrayBuffer;
picture_desc?: string;
}
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new ID3Writer(audioData);
// reserve original data
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []
frames.forEach(frame => {
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [];
frames.forEach((frame) => {
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
try {
writer.setFrame(frame.id, frame.value)
} catch (e) {
writer.setFrame(frame.id, frame.value);
} catch (e) {}
}
}
})
});
const old = original.common
writer.setFrame('TPE1', old?.artists || info.artists || [])
const old = original.common;
writer
.setFrame('TPE1', old?.artists || info.artists || [])
.setFrame('TIT2', old?.title || info.title)
.setFrame('TALB', old?.album || info.album || "");
.setFrame('TALB', old?.album || info.album || '');
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || "Cover",
})
description: info.picture_desc || '',
});
}
return writer.addTag();
}
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new MetaFlac(audioData)
const old = original.common
const writer = new MetaFlac(audioData);
const old = original.common;
if (!old.title && !old.album && old.artists) {
writer.setTag("TITLE=" + info.title)
writer.setTag("ALBUM=" + info.album)
writer.setTag('TITLE=' + info.title);
writer.setTag('ALBUM=' + info.album);
if (info.artists) {
writer.removeTag("ARTIST")
info.artists.forEach(artist => writer.setTag("ARTIST=" + artist))
writer.removeTag('ARTIST');
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
}
}
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 } {
const pos = n.lastIndexOf(".")
const pos = n.lastIndexOf('.');
return {
ext: n.substring(pos + 1).toLowerCase(),
name: n.substring(0, pos)
}
name: n.substring(0, pos),
};
}

View File

@@ -1,55 +1,57 @@
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {DecryptResult} from "@/decrypt/entity";
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils.ts";
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
import { DecryptResult } from '@/decrypt/entity';
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 MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]
const MagicHeader = [0x69, 0x66, 0x6d, 0x74];
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe];
const FileTypeMap: { [key: string]: string } = {
" WAV": ".wav",
"FLAC": ".flac",
" MP3": ".mp3",
" A4M": ".m4a",
}
' WAV': '.wav',
FLAC: '.flac',
' MP3': '.mp3',
' A4M': '.m4a',
};
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
if (raw_ext === "xm") {
throw Error("此xm文件已损坏")
if (raw_ext === 'xm') {
throw Error('此xm文件已损坏');
} 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)) {
throw Error("未知的.xm文件类型")
throw Error('未知的.xm文件类型');
}
let key = oriData[0xf]
let dataOffset = oriData[0xc] | oriData[0xd] << 8 | oriData[0xe] << 16
let key = oriData[0xf];
let dataOffset = oriData[0xc] | (oriData[0xd] << 8) | (oriData[0xe] << 16);
let audioData = oriData.slice(0x10);
let lenAudioData = audioData.length;
for (let cur = dataOffset; cur < lenAudioData; ++cur)
audioData[cur] = (audioData[cur] - key) ^ 0xff;
for (let cur = dataOffset; cur < lenAudioData; ++cur) audioData[cur] = (audioData[cur] - key) ^ 0xff;
const ext = FileTypeMap[typeText];
const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime });
const musicMeta = await metaParseBlob(musicBlob);
if (ext === "wav") {
if (ext === 'wav') {
//todo:未知的编码方式
console.info(musicMeta.common)
musicMeta.common.album = "";
musicMeta.common.artist = "";
musicMeta.common.title = "";
console.info(musicMeta.common);
musicMeta.common.album = '';
musicMeta.common.artist = '';
musicMeta.common.title = '';
}
const {title, artist} = GetMetaFromFile(raw_filename,
musicMeta.common.title, musicMeta.common.artist,
raw_filename.indexOf("_") === -1 ? "-" : "_")
const { title, artist } = GetMetaFromFile(
raw_filename,
musicMeta.common.title,
musicMeta.common.artist,
raw_filename.indexOf('_') === -1 ? '-' : '_',
);
return {
title,
@@ -60,7 +62,6 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
rawExt: "xm"
rawExt: 'xm',
};
}
}

View File

@@ -1,5 +1,2 @@
const bs = chrome || browser
bs.tabs.create({
url: bs.runtime.getURL('./index.html')
}, tab => console.log(tab))
const bs = chrome || browser;
bs.tabs.create({ 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,14 +1,18 @@
import Vue from 'vue'
import App from '@/App.vue'
import '@/registerServiceWorker'
import Vue from 'vue';
import App from '@/App.vue';
import '@/registerServiceWorker';
import {
Button,
Checkbox,
Col,
Container,
Dialog,
Form,
FormItem,
Footer,
Icon,
Image,
Input,
Link,
Main,
Notification,
@@ -19,13 +23,17 @@ import {
TableColumn,
Tooltip,
Upload,
MessageBox
MessageBox,
} from 'element-ui';
import 'element-ui/lib/theme-chalk/base.css';
Vue.use(Link);
Vue.use(Image);
Vue.use(Button);
Vue.use(Dialog);
Vue.use(Form);
Vue.use(FormItem);
Vue.use(Input);
Vue.use(Table);
Vue.use(TableColumn);
Vue.use(Main);
@@ -44,5 +52,5 @@ Vue.prototype.$confirm = MessageBox.confirm;
Vue.config.productionTip = false;
new Vue({
render: h => h(App),
render: (h) => h(App),
}).$mount('#app');

View File

@@ -1,31 +1,30 @@
/* eslint-disable no-console */
import {register} from 'register-service-worker'
if (process.env.NODE_ENV === 'production' && window.location.protocol === "https:") {
import { register } from 'register-service-worker';
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') {
register(`${process.env.BASE_URL}service-worker.js`, {
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() {
console.log('Service worker has been registered.')
console.log('Service worker has been registered.');
},
cached() {
console.log('Content has been cached for offline use.')
console.log('Content has been cached for offline use.');
},
updatefound() {
console.log('New content is downloading.')
console.log('New content is downloading.');
},
updated() {
console.log('New content is available.');
window.location.reload();
},
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) {
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 {
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 {
constructor(buffer: Buffer)
constructor(buffer: Buffer);
setTag(field: string)
setTag(field: string);
removeTag(name: string)
removeTag(name: string);
importPictureFromBuffer(picture: Buffer)
importPictureFromBuffer(picture: Buffer);
save(): Buffer
save(): Buffer;
}
}

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

@@ -1,58 +1,54 @@
export interface FileSystemGetFileOptions {
create?: boolean
create?: boolean;
}
interface FileSystemCreateWritableOptions {
keepExistingData?: boolean
keepExistingData?: boolean;
}
interface FileSystemRemoveOptions {
recursive?: boolean
recursive?: boolean;
}
interface FileSystemFileHandle {
getFile(): Promise<File>;
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
}
enum WriteCommandType {
write = "write",
seek = "seek",
truncate = "truncate",
write = 'write',
seek = 'seek',
truncate = 'truncate',
}
interface WriteParams {
type: WriteCommandType
size?: number
position?: number
data: BufferSource | Blob | string
type: WriteCommandType;
size?: number;
position?: number;
data: BufferSource | Blob | string;
}
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams;
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 {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>;
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>;
}
declare global {
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 {
namespace JSX {
// tslint:disable no-empty-interface
interface Element extends VNode {
}
interface Element extends VNode {}
// tslint:disable no-empty-interface
interface ElementClass extends Vue {
}
interface ElementClass extends Vue {}
interface IntrinsicElements {
[elem: string]: any
[elem: string]: any;
}
}
}

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

@@ -1,4 +1,4 @@
declare module '*.vue' {
import Vue from 'vue'
export default Vue
import Vue from '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 {
Found: boolean
HttpsFound: boolean
Version: string
URL: string
Detail: string
Found: boolean;
HttpsFound: boolean;
Version: string;
URL: string;
Detail: string;
}
export async function checkUpdate(version: string): Promise<UpdateInfo> {
const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({"Version": version})
});
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}),
const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Version: version }),
});
return await resp.json();
}
export interface CoverInfo {
Id: string
Type: number
Id: string;
Type: number;
}
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
const endpoint = IXAREA_API_ENDPOINT + "/music/qq-cover"
const params = new URLSearchParams([["Title", title], ["Artist", artist ?? ""], ["Album", album ?? ""]])
const resp = await fetch(`${endpoint}?${params.toString()}`)
return await resp.json()
const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover';
const params = new URLSearchParams([
['Title', title],
['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,5 +1,5 @@
import {DecryptResult} from "@/decrypt/entity";
import {FileSystemDirectoryHandle} from "@/shims-fs";
import { DecryptResult } from '@/decrypt/entity';
import { FileSystemDirectoryHandle } from '@/shims-fs';
export enum FilenamePolicy {
ArtistAndTitle,
@@ -8,12 +8,12 @@ export enum FilenamePolicy {
SameAsOriginal,
}
export const FilenamePolicies: { key: FilenamePolicy, text: string }[] = [
{key: FilenamePolicy.ArtistAndTitle, text: "歌手-歌曲名"},
{key: FilenamePolicy.TitleOnly, text: "歌曲名"},
{key: FilenamePolicy.TitleAndArtist, text: "歌曲名-歌手"},
{key: FilenamePolicy.SameAsOriginal, text: "同源文件名"},
]
export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [
{ key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' },
{ key: FilenamePolicy.TitleOnly, text: '歌曲名' },
{ key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' },
{ key: FilenamePolicy.SameAsOriginal, text: '同源文件名' },
];
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
switch (policy) {
@@ -30,24 +30,22 @@ export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy)
}
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
let filename = GetDownloadFilename(data, policy)
let filename = GetDownloadFilename(data, policy);
// prevent filename exist
try {
await dir.getFileHandle(filename)
filename = `${new Date().getTime()} - ${filename}`
} catch (e) {
}
const file = await dir.getFileHandle(filename, {create: true})
const w = await file.createWritable()
await w.write(data.blob)
await w.close()
await dir.getFileHandle(filename);
filename = `${new Date().getTime()} - ${filename}`;
} catch (e) {}
const file = await dir.getFileHandle(filename, { create: true });
const w = await file.createWritable();
await w.write(data.blob);
await w.close();
}
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
const a = document.createElement('a');
a.href = data.file;
a.download = GetDownloadFilename(data, policy)
a.download = GetDownloadFilename(data, policy);
document.body.append(a);
a.click();
a.remove();
@@ -55,7 +53,7 @@ export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
export function RemoveBlobMusic(data: DecryptResult) {
URL.revokeObjectURL(data.file);
if (data.picture?.startsWith("blob:")) {
if (data.picture?.startsWith('blob:')) {
URL.revokeObjectURL(data.picture);
}
}
@@ -64,16 +62,19 @@ export class DecryptQueue {
private readonly pending: (() => Promise<void>)[];
constructor() {
this.pending = []
this.pending = [];
}
queue(fn: () => Promise<void>) {
this.pending.push(fn)
this.consume()
this.pending.push(fn);
this.consume();
}
private consume() {
const fn = this.pending.shift()
if (fn) fn().then(() => this.consume).catch(console.error)
const fn = this.pending.shift();
if (fn)
fn()
.then(() => this.consume)
.catch(console.error);
}
}

View File

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

View File

@@ -5,18 +5,24 @@
<div id="app-control">
<el-row class="mb-3">
<span>歌曲命名格式</span>
<el-radio v-for="k in FilenamePolicies" :key="k.key"
v-model="filename_policy" :label="k.key">
<el-radio v-for="k in FilenamePolicies" :key="k.key" v-model="filename_policy" :label="k.key">
{{ k.text }}
</el-radio>
</el-row>
<el-row>
<config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog>
<el-tooltip class="item" effect="dark" placement="top">
<div slot="content">
<span> 部分解密方案需要设定解密参数 </span>
</div>
<el-button icon="el-icon-s-tools" plain @click="showConfigDialog = true">解密设定</el-button>
</el-tooltip>
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button>
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
<el-tooltip class="item" effect="dark" placement="top-start">
<div slot="content">
<span v-if="instant_save">工作模式: {{ dir ? "写入本地文件系统" : "调用浏览器下载" }}</span>
<span v-if="instant_save">工作模式: {{ dir ? '写入本地文件系统' : '调用浏览器下载' }}</span>
<span v-else>
当您使用此工具进行大量文件解锁的时候建议开启此选项<br />
开启后解锁结果将不会存留于浏览器中防止内存不足
@@ -34,62 +40,68 @@
</template>
<script>
import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable';
import ConfigDialog from '@/component/ConfigDialog';
import FileSelector from "@/component/FileSelector"
import PreviewTable from "@/component/PreviewTable"
import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
export default {
name: 'Home',
components: {
FileSelector,
PreviewTable
PreviewTable,
ConfigDialog,
},
data() {
return {
showConfigDialog: false,
tableData: [],
playing_url: "",
playing_url: '',
playing_auto: false,
filename_policy: FilenamePolicy.ArtistAndTitle,
instant_save: false,
FilenamePolicies,
dir: null
}
dir: null,
};
},
watch: {
instant_save(val) {
if (val) this.showDirectlySave()
}
if (val) this.showDirectlySave();
},
},
methods: {
async showSuccess(data) {
if (this.instant_save) {
await this.saveFile(data)
await this.saveFile(data);
RemoveBlobMusic(data);
} else {
this.tableData.push(data);
this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000
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)]);
window._paq.push(['trackEvent', 'Unlock', data.rawExt + ',' + data.mime, JSON.stringify(_rp_data)]);
}
},
showFail(errInfo, filename) {
console.error(errInfo, filename)
console.error(errInfo, filename);
this.$notify.error({
title: '出现问题',
message: errInfo + "" + filename +
message:
errInfo +
'' +
filename +
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 6000
duration: 6000,
});
if (process.env.NODE_ENV === 'production') {
window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
window._paq.push(['trackEvent', 'Error', String(errInfo), filename]);
}
},
changePlaying(url) {
@@ -97,16 +109,19 @@ export default {
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach(value => {
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])
this.saveFile(this.tableData[index]);
index++;
} else {
clearInterval(c);
@@ -116,42 +131,40 @@ export default {
async saveFile(data) {
if (this.dir) {
await DirectlyWriteFile(data, this.filename_policy, this.dir)
await DirectlyWriteFile(data, this.filename_policy, this.dir);
this.$notify({
title: "保存成功",
title: '保存成功',
message: data.title,
position: "top-left",
type: "success",
duration: 3000
})
position: 'top-left',
type: 'success',
duration: 3000,
});
} else {
DownloadBlobMusic(data, this.filename_policy)
DownloadBlobMusic(data, this.filename_policy);
}
},
async showDirectlySave() {
if (!window.showDirectoryPicker) return
if (!window.showDirectoryPicker) return;
try {
await this.$confirm("您的浏览器支持文件直接保存到磁盘,是否使用?",
"新特性提示", {
confirmButtonText: "使用",
cancelButtonText: "不使用",
type: "warning",
center: true
})
await this.$confirm('您的浏览器支持文件直接保存到磁盘,是否使用?', '新特性提示', {
confirmButtonText: '使用',
cancelButtonText: '不使用',
type: 'warning',
center: true,
});
} catch (e) {
console.log(e)
return
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)
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)
}
console.error(e);
}
},
}
},
};
</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,
"baseUrl": ".",
"types": [
"webpack-env"
"webpack-env",
"jest"
],
"paths": {
"@/*": [