mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 19:43:02 +00:00
Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c96f264b | ||
|
|
c5bc436ab2 | ||
|
|
486f1fe898 | ||
|
|
8b628fd6ce | ||
|
|
bb9529b877 | ||
|
|
985620d188 | ||
|
|
22528481d5 | ||
|
|
c1e17992e9 | ||
|
|
f478ca8818 | ||
|
|
8e4367fbf9 | ||
|
|
1ae2f93e99 | ||
|
|
741e302ea7 | ||
|
|
f09aa84984 | ||
|
|
1d2296a02a | ||
|
|
f6703160e7 | ||
|
|
bea2f4b7d4 | ||
|
|
0a3dac9d3d | ||
|
|
602d6865f5 | ||
|
|
8d4194772e | ||
|
|
1ef1db30ab | ||
|
|
80fc595833 | ||
|
|
be9a1b6724 | ||
|
|
6cccb722ce | ||
|
|
2f9cfaa763 | ||
|
|
5d7f5b76ef | ||
|
|
2d50a45ef2 | ||
|
|
fcc4b14211 | ||
|
|
6c21150fc8 | ||
|
|
e98470cb70 | ||
|
|
bb8f69f137 | ||
|
|
f194dfd135 | ||
|
|
7e741412a8 | ||
|
|
fe39ac6604 | ||
|
|
d781767dd0 | ||
|
|
a7158a75e9 | ||
|
|
2bd35f899d | ||
|
|
bb37da5066 | ||
|
|
27a91a67bb | ||
|
|
067ad6e40b | ||
|
|
c95bcd7eda | ||
|
|
caed717755 | ||
|
|
16bb7cb0fb | ||
|
|
24fc661953 | ||
|
|
6b3ce9b031 | ||
|
|
b2528969a0 | ||
|
|
c89801a950 | ||
|
|
314e87f448 | ||
|
|
935d6a776a | ||
|
|
6f733864b7 | ||
|
|
e8d1dba0e2 | ||
|
|
9f1c8877db | ||
|
|
889a6cbcd0 | ||
|
|
83b06dbe60 | ||
|
|
e2b7a753dc | ||
|
|
4498ab6592 | ||
|
|
da853ba6e0 | ||
|
|
18d02a906b | ||
|
|
06f257d3bb | ||
|
|
ecc34aaf44 | ||
|
|
85ab69d41d | ||
|
|
fbf7749e55 | ||
|
|
a0bab29966 | ||
|
|
e678e40b86 | ||
|
|
c39d2edce7 | ||
|
|
31420ac515 | ||
|
|
82f15e204c | ||
|
|
a1044b0c3a | ||
|
|
bf7fc908bc | ||
|
|
4905511d71 | ||
|
|
022c9d1eac |
10
.drone.yml
10
.drone.yml
@@ -5,7 +5,7 @@ name: default
|
||||
|
||||
steps:
|
||||
- name: test & build
|
||||
image: node:18.16.1-bookworm
|
||||
image: node:20.10.0-bookworm
|
||||
commands:
|
||||
# - git config --global --add safe.directory "/drone/src"
|
||||
- corepack enable
|
||||
@@ -17,7 +17,7 @@ steps:
|
||||
npm_config_registry: https://registry.npmmirror.com
|
||||
|
||||
- name: publish
|
||||
image: node:18.16.1-bookworm
|
||||
image: node:20.10.0-bookworm
|
||||
environment:
|
||||
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
||||
GITEA_API_KEY:
|
||||
@@ -27,7 +27,9 @@ steps:
|
||||
NETLIFY_API_KEY:
|
||||
from_secret: NETLIFY_API_KEY
|
||||
commands:
|
||||
# - git config --global --add safe.directory "/drone/src"
|
||||
- python3 -m zipfile -c um-react.zip dist/.
|
||||
- |
|
||||
python3 -m zipfile -c um-react.zip dist/.
|
||||
cp um-react.zip dist/"release-${DRONE_COMMIT_SHA}.zip"
|
||||
python3 -m zipfile -c um-react-site.zip dist/.
|
||||
- ./scripts/publish.sh
|
||||
- ./scripts/deploy.sh
|
||||
|
||||
2
.env
2
.env
@@ -1,4 +1,4 @@
|
||||
# Example environment file for vite to use.
|
||||
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
||||
|
||||
ENABLE_PERF_LOG=0
|
||||
VITE_ENABLE_PERF_LOG=0
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -27,3 +27,8 @@ dist-ssr
|
||||
# Files created when running "drone exec" locally
|
||||
/.pnpm-store/
|
||||
/*.zip
|
||||
|
||||
/um-react-wry-*
|
||||
/um-react*.exe
|
||||
|
||||
/win64/
|
||||
|
||||
6
.npmrc
6
.npmrc
@@ -1,3 +1,5 @@
|
||||
use-node-version=18.16.0
|
||||
node-version=18.16.0
|
||||
use-node-version=20.10.0
|
||||
node-version=20.10.0
|
||||
engine-strict=true
|
||||
@um:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
||||
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
||||
|
||||
12
.run/test.run.xml
Normal file
12
.run/test.run.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="test" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="test" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
12
.run/vite dev.run.xml
Normal file
12
.run/vite dev.run.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="vite dev" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="start" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
55
README.MD
55
README.MD
@@ -3,6 +3,7 @@
|
||||
[](https://ci.unlock-music.dev/um/um-react)
|
||||
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
||||
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
||||
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
||||
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
||||
@@ -10,23 +11,37 @@
|
||||
- [常见问题参考](./docs/faq_zh-hans.md)
|
||||
|
||||
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
||||
[um-vue]: https://git.unlock-music.dev/um/web
|
||||
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
|
||||
|
||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||
|
||||
## 支持的格式
|
||||
|
||||
- [x] QQ 音乐 QMCv1 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
|
||||
- [x] QQ 音乐 QMCv2 PC 端 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
|
||||
- [x] 网易云音乐 (.ncm)
|
||||
- [x] 虾米音乐 (.xm)
|
||||
- [x] 酷我音乐 (.kwm)
|
||||
- [x] 酷狗音乐 (.kgm/.vpr)
|
||||
- [x] 喜马拉雅 Android 端 (.x2m/.x3m)
|
||||
- [x] 咪咕音乐格式 (.mg3d)
|
||||
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~
|
||||
- [x] QQ 音乐 QMCv1 (`.qmc3` / `.qmcflac` 等)
|
||||
- [x] QQ 音乐 QMCv2
|
||||
- PC 客户端 (`.mflac` / `.mgg` 等) [^qm-key-pc]
|
||||
- 安卓客户端 (`.mflac0` / `.mgg1` / `.mggl` 等) [^qm-key-android]
|
||||
- iOS 客户端 (`.mgalaxy` 等) [^qm-key-ios]
|
||||
- Mac 客户端 (`.mflach` 等) [^qm-key-mac]
|
||||
- [x] 网易云音乐 (`.ncm`)
|
||||
- [x] 虾米音乐 (`.xm`)
|
||||
- [x] 酷我音乐 (`.kwm`)
|
||||
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
|
||||
- [x] 喜马拉雅 Android 端 (`.x2m` / `.x3m`)
|
||||
- [x] 咪咕音乐格式 (`.mg3d`)
|
||||
- [x] 蜻蜓 FM (`.qta`)
|
||||
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
|
||||
|
||||
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
||||
[^qm-key-pc]: PC 客户端仅支持 v19.43 或更低版本。
|
||||
[^qm-key-android]: 需要获取超级管理员权限后提取密钥数据库,并导入后使用。
|
||||
[^qm-key-ios]: 需要越狱获取密钥数据库,或对设备进行完整备份后提取密钥数据库,并导入后使用。
|
||||
[^qm-key-mac]: 需要导入密钥数据库。
|
||||
|
||||
不支持的格式?请提交样本(加密文件)与客户端信息(或一并上传其安装包)到[仓库的问题追踪区][project-issues]
|
||||
。如果文件太大,请上传到不需要登入下载的网盘,如 [mega.nz](https://mega.nz)、[OneDrive](https://www.onedrive.com/) 等。
|
||||
|
||||
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。
|
||||
|
||||
@@ -36,11 +51,11 @@
|
||||
|
||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||
|
||||
### 面向 libparakeet SDK 开发
|
||||
### 解密库开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||
|
||||
请参考文档「[面向 `libparakeet-js` 开发](./docs/develop-with-libparakeet.zh.md)」。
|
||||
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh)」。
|
||||
|
||||
### 架构
|
||||
|
||||
@@ -59,12 +74,26 @@
|
||||
|
||||
满足上述条件后发起 Pull Request,仓库管理员审阅后将合并到主分支。
|
||||
|
||||
## 相关项目
|
||||
|
||||
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
||||
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
|
||||
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron)
|
||||
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
||||
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
|
||||
|
||||
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
|
||||
|
||||
有新的项目提交?欢迎[提交 issue][project-issues],请带上项目名称和链接。
|
||||
|
||||
## TODO
|
||||
|
||||
- 待定
|
||||
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
|
||||
- [ ] #7 简易元数据编辑器
|
||||
- 完成
|
||||
- [x] #7 ~~简易元数据编辑器~~ 放弃
|
||||
- [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。
|
||||
- [x] #2 解密内容探测 (解密过程)
|
||||
- [x] #6 文件拖放 (利用 `react-dropzone`?)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
# 面向 `libparakeet-js` 开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
||||
|
||||
`libparakeet-js` 编译目前需要 Linux 环境,请参考[仓库说明][libparakeet-js-doc]。
|
||||
|
||||
该文档将假设这两个项目被放置在同级的目录下:
|
||||
|
||||
```text
|
||||
~/Projects/um-projects
|
||||
/um-react
|
||||
/libparakeet-js
|
||||
```
|
||||
|
||||
若为不同目录,你需要调整 `LIB_PARAKEET_JS_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
||||
|
||||
[libparakeet-js-doc]: https://github.com/parakeet-rs/libparakeet-js/blob/main/README.MD
|
||||
|
||||
## 初次构建
|
||||
|
||||
- 进入上层目录:`cd ..`
|
||||
- 克隆 `libparakeet-js` 仓库 (目前需要 Linux 环境, Windows 下推荐使用 WSL2)
|
||||
- `git clone --recurse-submodules https://github.com/parakeet-rs/libparakeet-js.git`
|
||||
- 进入 SDK 目录:`cd libparakeet-js`
|
||||
- 如果需要更新 `submodule`:`git submodule update --init --recursive`
|
||||
- 构建所有代码:`make all`
|
||||
|
||||
如果需要手动控制构建过程,你也可以:
|
||||
|
||||
- 运行 `./build.sh -j 4` 进行 C++ 到 WebAssembly 编译过程
|
||||
- 此处的 `4` 是并行编译数量,该值通常略小于 CPU 核心数。
|
||||
- 若是不指定并行数量,则使用当前核心数。
|
||||
- 编译 `js-sdk`:
|
||||
- 进入 `npm` 目录:`cd npm`
|
||||
- 安装依赖:`pnpm i --frozen-lockfile`
|
||||
- 构建:`pnpm build`
|
||||
|
||||
## 做出更改
|
||||
|
||||
做出更改后,参考上面的内容进行重新编译。
|
||||
|
||||
## 应用 SDK 更改
|
||||
|
||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||
|
||||
```sh
|
||||
pnpm link ../libparakeet-js/npm
|
||||
```
|
||||
|
||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
||||
36
docs/develop-with-um_crypto.zh.md
Normal file
36
docs/develop-with-um_crypto.zh.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 面向 `@unlock-music/crypto` 开发
|
||||
|
||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该文档。
|
||||
|
||||
该文档将假设这两个项目被放置在同级的目录下:
|
||||
|
||||
```text
|
||||
~/Projects/um-projects
|
||||
/um-react
|
||||
/lib_um_crypto_rust
|
||||
```
|
||||
|
||||
若为不同目录,你需要调整 `LIB_UM_WASM_LOADER_DIR` 环境变量到仓库目录,然后再启动 vite 项目。
|
||||
|
||||
## 初次构建
|
||||
|
||||
- 进入上层目录:`cd ..`
|
||||
- 克隆 `lib_um_crypto_rust` 仓库
|
||||
- `git clone https://git.unlock-music.dev/um/lib_um_crypto_rust.git`
|
||||
- 进入 SDK 目录:`cd lib_um_crypto_rust ; cd um_wasm_loader`
|
||||
- 安装所有 Node 以来:`pnpm i`
|
||||
- 构建:`pnpm build`
|
||||
|
||||
## 做出更改
|
||||
|
||||
做出更改后,参考上面的内容进行重新编译。
|
||||
|
||||
## 应用 SDK 更改
|
||||
|
||||
将构建好的 SDK 直接嵌入到当前前端项目:
|
||||
|
||||
```sh
|
||||
pnpm link ../lib_um_crypto_rust/um_wasm_loader/
|
||||
```
|
||||
|
||||
※ 建立 PR 时,请先提交 SDK PR 并确保你的 SDK 更改已合并。
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
#### 2、检查您的平台。
|
||||
|
||||
日前,<mark>仅 Windows 客户端</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||
|
||||
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
|
||||
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密,手机平台的其他音质暂时不需要提取密钥,PC 平台暂未推出使用新版加密的音质。
|
||||
|
||||
※ 已知部分第三方修改版会破坏密钥写出功能,导致无法导入密钥。请使用官方版本。
|
||||
|
||||
> Android 用户提取密钥需要 root,或者注入文件提供器。
|
||||
|
||||
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
|
||||
@@ -54,6 +56,37 @@
|
||||
|
||||
目前新版没有做歌曲信息匹配与编辑,所以歌曲如果自己没有写入歌曲信息,解出来就是没有的。
|
||||
|
||||
### 安卓 root 相关
|
||||
|
||||
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
|
||||
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
|
||||
|
||||
如果希望不破坏系统完整性,你可以考虑使用模拟器。
|
||||
|
||||
※ **注意**:根据应用厂商的风控策略,使用模拟器登录的账号**有可能会被封锁**;使用前请自行评估风险。
|
||||
|
||||
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11 开始支援的适用于 Android™ 的 Windows 子系统 (WSA)。
|
||||
|
||||
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
|
||||
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
|
||||

|
||||
|
||||
### Via 等浏览器无法正常解密/下载
|
||||
|
||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||
|
||||
已知有问题的浏览器:
|
||||
|
||||
- Via 浏览器
|
||||
- 夸克浏览器
|
||||
- UC 浏览器
|
||||
|
||||
可能会遇到的问题包括:
|
||||
|
||||
- 网页白屏
|
||||
- 无法下载解密后内容
|
||||
- 下载的文件名错误
|
||||
|
||||
### 新版解锁网站没有批量下载
|
||||
|
||||
目前没有做。抱歉。
|
||||
|
||||
@@ -33,3 +33,31 @@ pnpm build
|
||||
如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。
|
||||
|
||||
[vite-preview-url]: http://localhost:4173/
|
||||
|
||||
## 打包 `.zip`
|
||||
|
||||
建议在 Linux 环境下执行,可参考 `.drone.yml` CI 文件。
|
||||
|
||||
1. 确保上述的构建步骤已完成。
|
||||
2. 确保 `python3` 已安装。
|
||||
3. 执行下述代码
|
||||
```sh
|
||||
python3 -m zipfile -c um-react.zip dist/.
|
||||
```
|
||||
|
||||
## 打包 win64 单文件
|
||||
|
||||
利用 Windows 系统自带的 [Edge WebView2 组件](https://learn.microsoft.com/zh-cn/microsoft-edge/webview2/)
|
||||
和 [wry](https://github.com/tauri-apps/wry) 进行一个单文件的打包。
|
||||
|
||||
大部分 Windows 10 或以上版本的操作系统已经集成了 WebView2 运行时。若无法正常启动,请[下载并安装 Edge WebView2 运行时](https://go.microsoft.com/fwlink/p/?LinkId=2124703)。
|
||||
|
||||
其它系统兼容性未知。
|
||||
|
||||
1. 确保你现在在 `linux-amd64` 环境下。
|
||||
2. 确保上述的 `um-react.zip` 构建已完成。
|
||||
3. 执行下述代码
|
||||
```sh
|
||||
./scripts/make-win64.sh
|
||||
```
|
||||
4. 等待提示 `[Build OK]` 即可。
|
||||
|
||||
95
package.json
95
package.json
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "um-react",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "tsc -p tsconfig.prod.json && vite build",
|
||||
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
|
||||
"build:finalize": "node scripts/write-version.mjs && node scripts/minify-mjs.mjs",
|
||||
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"format": "prettier -w .",
|
||||
"test": "vitest run",
|
||||
@@ -16,56 +17,58 @@
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.2.1",
|
||||
"@chakra-ui/anatomy": "^2.2.2",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/react": "^2.8.1",
|
||||
"@chakra-ui/react": "^2.8.2",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@jixun/libparakeet": "0.3.0",
|
||||
"@reduxjs/toolkit": "^1.9.7",
|
||||
"framer-motion": "^10.16.4",
|
||||
"immer": "^10.0.3",
|
||||
"nanoid": "^5.0.1",
|
||||
"radash": "^11.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@reduxjs/toolkit": "^2.0.1",
|
||||
"@unlock-music/crypto": "0.1.0",
|
||||
"framer-motion": "^11.5.6",
|
||||
"nanoid": "^5.0.7",
|
||||
"radash": "^12.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-icons": "^4.11.0",
|
||||
"react-icons": "^5.3.0",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-redux": "^8.1.3",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-syntax-highlighter": "^15.5.0",
|
||||
"sass": "^1.69.2",
|
||||
"sql.js": "^1.8.0"
|
||||
"sass": "^1.79.3",
|
||||
"sql.js": "^1.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^5.0.3",
|
||||
"@testing-library/jest-dom": "^6.1.3",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^14.5.1",
|
||||
"@types/node": "^20.8.4",
|
||||
"@types/react": "^18.2.28",
|
||||
"@types/react-dom": "^18.2.13",
|
||||
"@types/react-syntax-highlighter": "^15.5.8",
|
||||
"@types/sql.js": "^1.4.5",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
||||
"@typescript-eslint/parser": "^6.7.5",
|
||||
"@vitejs/plugin-react": "^4.1.0",
|
||||
"@vitest/coverage-v8": "^0.34.6",
|
||||
"@vitest/ui": "^0.34.6",
|
||||
"eslint": "^8.51.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"husky": "^8.0.3",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^14.0.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.4.11",
|
||||
"vite-plugin-pwa": "^0.16.5",
|
||||
"vite-plugin-top-level-await": "^1.3.1",
|
||||
"vite-plugin-wasm": "^3.2.2",
|
||||
"vitest": "^0.34.6"
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@testing-library/jest-dom": "^6.5.0",
|
||||
"@testing-library/react": "^16.0.1",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.6.1",
|
||||
"@types/react": "^18.3.9",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.7.0",
|
||||
"@typescript-eslint/parser": "^8.7.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"@vitest/coverage-v8": "^2.1.1",
|
||||
"@vitest/ui": "^2.1.1",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.12",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.2.10",
|
||||
"prettier": "^3.3.3",
|
||||
"rollup": "^4.22.4",
|
||||
"terser": "^5.33.0",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.7",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^2.1.1",
|
||||
"workbox-window": "^7.1.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*": "prettier --write --ignore-unknown",
|
||||
@@ -78,8 +81,8 @@
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
|
||||
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
|
||||
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
|
||||
"sql.js": "patches/sql.js.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
||||
index e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644
|
||||
index b16cee5c3cbdf523f9beae920258094ae7fcbd0f..ae67be7145625c60995c5044860e87d6144a8837 100644
|
||||
--- a/dist/sql-wasm.js
|
||||
+++ b/dist/sql-wasm.js
|
||||
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) {
|
||||
@@ -187,3 +187,6 @@ else if (typeof define === 'function' && define['amd']) {
|
||||
else if (typeof exports === 'object'){
|
||||
exports["Module"] = initSqlJs;
|
||||
}
|
||||
+
|
||||
+var module;
|
||||
+export default initSqlJs;
|
||||
+
|
||||
11558
pnpm-lock.yaml
generated
11558
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -96,7 +96,7 @@ deploy_netlify() {
|
||||
echo " * ${error_message}"
|
||||
return 1
|
||||
else
|
||||
echo 'Deoployed to main url.'
|
||||
echo 'Deployed to main url.'
|
||||
fi
|
||||
fi
|
||||
}
|
||||
@@ -104,7 +104,7 @@ deploy_netlify() {
|
||||
# For deployment, we care a bit less
|
||||
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
||||
echo "Deploy to netlify..."
|
||||
deploy_netlify um-react.zip
|
||||
deploy_netlify um-react-site.zip
|
||||
else
|
||||
echo "skip netlify deployment."
|
||||
fi
|
||||
|
||||
33
scripts/make-win64.sh
Executable file
33
scripts/make-win64.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
|
||||
# sudo apt install -y jq zip
|
||||
|
||||
pushd "$(dirname "${BASH_SOURCE[0]}")/../"
|
||||
|
||||
WRY_VER="0.1.1"
|
||||
|
||||
mkdir -p win64/{deps,dist}
|
||||
dl_file() {
|
||||
local FILE="$1"
|
||||
if [[ ! -f "win64/deps/$FILE" ]]; then
|
||||
curl -fsL "https://um-react.app/files/${FILE}.gz" | gzip -d >"win64/deps/${FILE}"
|
||||
fi
|
||||
}
|
||||
|
||||
dl_file "um-react-wry-builder-${WRY_VER}-linux-amd64"
|
||||
dl_file "um-react-wry-stub-${WRY_VER}-win64.exe"
|
||||
chmod a+x win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64
|
||||
|
||||
APP_VERSION="$(jq -r '.version' <package.json)"
|
||||
EXE_NAME="um-react-win64-${APP_VERSION}.exe"
|
||||
ZIP_NAME="um-react-win64-${APP_VERSION}.zip"
|
||||
"./win64/deps/um-react-wry-builder-${WRY_VER}-linux-amd64" \
|
||||
-t "win64/deps/um-react-wry-stub-${WRY_VER}-win64.exe" \
|
||||
-r um-react.zip \
|
||||
-o "win64/dist/${EXE_NAME}"
|
||||
|
||||
touch -d 1970-01-01T00:00:00Z "win64/dist/${EXE_NAME}"
|
||||
zip -9oX "win64/dist/${ZIP_NAME}" -- "win64/dist/${EXE_NAME}"
|
||||
echo "[Build OK] 'win64/dist/${ZIP_NAME}'."
|
||||
|
||||
popd
|
||||
19
scripts/minify-mjs.mjs
Normal file
19
scripts/minify-mjs.mjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { minify } from 'terser';
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
|
||||
for (const file of readdirSync('dist/assets')) {
|
||||
if (!/\.(mjs|js)$/.test(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`minifying ${file}...`);
|
||||
const isModule = /\.mjs$/.test(file);
|
||||
|
||||
const output = await minify(readFileSync(`dist/assets/${file}`, 'utf-8'), {
|
||||
compress: true,
|
||||
mangle: true,
|
||||
module: isModule,
|
||||
});
|
||||
|
||||
writeFileSync(`dist/assets/${file}`, output.code);
|
||||
}
|
||||
14
scripts/write-version.mjs
Normal file
14
scripts/write-version.mjs
Normal file
@@ -0,0 +1,14 @@
|
||||
/* eslint-env node */
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
||||
|
||||
const pkgJson = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
|
||||
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash ?? 'unknown'}` + '\n';
|
||||
writeFileSync(__dirname + '/../dist/version.txt', pkgVer, 'utf-8');
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Box,
|
||||
Code,
|
||||
Heading,
|
||||
Link,
|
||||
ListItem,
|
||||
OrderedList,
|
||||
Text,
|
||||
@@ -19,6 +18,7 @@ import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/githu
|
||||
|
||||
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
||||
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
||||
import { ExtLink } from '../ExtLink';
|
||||
|
||||
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
||||
@@ -36,10 +36,19 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
|
||||
return (
|
||||
<>
|
||||
<Text>
|
||||
你需要 <code>root</code> 访问权限来访问安卓应用的私有数据。
|
||||
你需要
|
||||
<ruby>
|
||||
超级管理员
|
||||
<rp> (</rp>
|
||||
<rt>
|
||||
<code>root</code>
|
||||
</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
访问权限来访问安卓应用的私有数据。
|
||||
</Text>
|
||||
<Text>
|
||||
⚠️ 请注意,获取 <code>root</code> 通常意味着你的安卓设备
|
||||
⚠️ 请注意,获取管理员权限通常意味着你的安卓设备
|
||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||
</Text>
|
||||
|
||||
@@ -92,13 +101,13 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
确保 <code>adb</code> 命令可用。
|
||||
确保 <Code>adb</Code> 命令可用。
|
||||
</Text>
|
||||
<Text>
|
||||
💡 如果没有,可以
|
||||
<Link href="https://scoop.sh/#/apps?q=adb" isExternal>
|
||||
<ExtLink href="https://scoop.sh/#/apps?q=adb">
|
||||
使用 Scoop 安装 <ExternalLinkIcon />
|
||||
</Link>
|
||||
</ExtLink>
|
||||
。
|
||||
</Text>
|
||||
</ListItem>
|
||||
@@ -134,6 +143,11 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
|
||||
</Heading>
|
||||
<AccordionPanel pb={4}>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
确保 <Code>adb</Code> 命令可用。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>将安卓设备连接到电脑,并允许调试。</Text>
|
||||
</ListItem>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function AppRoot() {
|
||||
</Tab>
|
||||
<Tab>
|
||||
<Icon as={MdQuestionAnswer} mr="1" />
|
||||
<chakra.span>问答</chakra.span>
|
||||
<chakra.span>答疑</chakra.span>
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
|
||||
12
src/components/ExtLink.tsx
Normal file
12
src/components/ExtLink.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { AnchorHTMLAttributes } from 'react';
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { Link } from '@chakra-ui/react';
|
||||
|
||||
export function ExtLink({ children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||
return (
|
||||
<Link isExternal {...props} rel="noreferrer noopener nofollow">
|
||||
{children}
|
||||
<ExternalLinkIcon />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,9 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
|
||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||
</Center>
|
||||
|
||||
<Text mt={2}>选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</Text>
|
||||
<Text as="div" mt={2}>
|
||||
选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:
|
||||
</Text>
|
||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||
{children}
|
||||
</Flex>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||
import { strlen } from './strlen';
|
||||
|
||||
export interface KuwoHeader {
|
||||
rid: string; // uint64
|
||||
encVersion: 1 | 2; // uint32
|
||||
quality: string;
|
||||
}
|
||||
|
||||
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
|
||||
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
|
||||
if (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
|
||||
return null; // not kuwo-encrypted file
|
||||
}
|
||||
|
||||
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
|
||||
const qualityLen = strlen(qualityBytes);
|
||||
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
|
||||
|
||||
return {
|
||||
encVersion: view.getUint32(0x10, true) as 1 | 2,
|
||||
rid: view.getUint32(0x18, true).toString(),
|
||||
quality,
|
||||
};
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export function strlen(data: Uint8Array): number {
|
||||
const n = data.byteLength;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (data[i] === 0) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
85
src/decrypt-worker/Deciphers.ts
Normal file
85
src/decrypt-worker/Deciphers.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NetEaseCloudMusicDecipher } from '~/decrypt-worker/decipher/NetEaseCloudMusic.ts';
|
||||
import { TransparentDecipher } from './decipher/Transparent.ts';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { QQMusicV1Decipher, QQMusicV2Decipher } from '~/decrypt-worker/decipher/QQMusic.ts';
|
||||
import { KuwoMusicDecipher } from '~/decrypt-worker/decipher/KuwoMusic.ts';
|
||||
import { KugouMusicDecipher } from '~/decrypt-worker/decipher/KugouMusic.ts';
|
||||
import { XimalayaAndroidDecipher, XimalayaPCDecipher } from '~/decrypt-worker/decipher/Ximalaya.ts';
|
||||
import { XiamiDecipher } from '~/decrypt-worker/decipher/XiamiMusic.ts';
|
||||
import { QignTingFMDecipher } from '~/decrypt-worker/decipher/QingTingFM.ts';
|
||||
import { Migu3DKeylessDecipher } from '~/decrypt-worker/decipher/Migu3d.ts';
|
||||
|
||||
export enum Status {
|
||||
OK = 0,
|
||||
NOT_THIS_CIPHER = 1,
|
||||
FAILED = 2,
|
||||
}
|
||||
|
||||
export type DecipherResult = DecipherOK | DecipherNotOK;
|
||||
|
||||
export interface DecipherNotOK {
|
||||
status: Exclude<Status, Status.OK>;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface DecipherOK {
|
||||
status: Status.OK;
|
||||
message?: string;
|
||||
data: Uint8Array;
|
||||
overrideExtension?: string;
|
||||
cipherName: string;
|
||||
}
|
||||
|
||||
export interface DecipherInstance {
|
||||
cipherName: string;
|
||||
|
||||
decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK>;
|
||||
}
|
||||
|
||||
export type DecipherFactory = () => DecipherInstance;
|
||||
|
||||
export const allCryptoFactories: DecipherFactory[] = [
|
||||
/// File with fixed headers goes first.
|
||||
|
||||
// NCM (*.ncm)
|
||||
NetEaseCloudMusicDecipher.make,
|
||||
|
||||
// KGM (*.kgm, *.vpr)
|
||||
KugouMusicDecipher.make,
|
||||
|
||||
// KWMv1 (*.kwm)
|
||||
KuwoMusicDecipher.make,
|
||||
|
||||
// Ximalaya PC (*.xm)
|
||||
XimalayaPCDecipher.make,
|
||||
|
||||
// Xiami (*.xm)
|
||||
XiamiDecipher.make,
|
||||
|
||||
// QingTingFM Android (*.qta)
|
||||
QignTingFMDecipher.make,
|
||||
|
||||
/// File with a fixed footer goes second
|
||||
|
||||
// QMCv2 (*.mflac)
|
||||
QQMusicV2Decipher.createWithUserKey,
|
||||
QQMusicV2Decipher.createWithEmbeddedEKey,
|
||||
|
||||
/// File without an obvious header or footer goes last.
|
||||
|
||||
// Migu3D/Keyless (*.wav; *.m4a)
|
||||
Migu3DKeylessDecipher.make,
|
||||
|
||||
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
||||
// should be moved to the bottom of the list for performance reasons.
|
||||
|
||||
// QMCv1 (*.qmcflac)
|
||||
QQMusicV1Decipher.create,
|
||||
|
||||
// Ximalaya (Android)
|
||||
XimalayaAndroidDecipher.makeX2M,
|
||||
XimalayaAndroidDecipher.makeX3M,
|
||||
|
||||
// Transparent crypto (not encrypted)
|
||||
TransparentDecipher.make,
|
||||
];
|
||||
@@ -1,5 +1,8 @@
|
||||
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||
DECRYPT = 'DECRYPT',
|
||||
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
||||
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
|
||||
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
||||
VERSION = 'VERSION',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||
|
||||
export interface CryptoBase {
|
||||
cryptoName: string;
|
||||
checkByDecryptHeader: boolean;
|
||||
|
||||
/**
|
||||
* If set, this new extension will be used instead.
|
||||
* Useful for non-audio format, e.g. qrc to lrc/xml.
|
||||
*/
|
||||
overrideExtension?: string;
|
||||
|
||||
checkBySignature?: (buffer: ArrayBuffer, options: DecryptCommandOptions) => Promise<boolean>;
|
||||
decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob | ArrayBuffer>;
|
||||
}
|
||||
|
||||
export type CryptoFactory = () => CryptoBase;
|
||||
@@ -1,45 +0,0 @@
|
||||
import { CryptoFactory } from './CryptoBase';
|
||||
|
||||
import { QMC1Crypto } from './qmc/qmc_v1';
|
||||
import { QMC2Crypto, QMC2CryptoWithKey } from './qmc/qmc_v2';
|
||||
import { XiamiCrypto } from './xiami/xiami';
|
||||
import { KGMCrypto } from './kgm/kgm_pc';
|
||||
import { NCMCrypto } from './ncm/ncm_pc';
|
||||
import { XimalayaAndroidCrypto } from './xmly/xmly_android';
|
||||
import { KWMCrypto } from './kwm/kwm';
|
||||
import { MiguCrypto } from './migu/migu3d_keyless';
|
||||
import { TransparentCrypto } from './transparent/transparent';
|
||||
|
||||
export const allCryptoFactories: CryptoFactory[] = [
|
||||
// Xiami (*.xm)
|
||||
XiamiCrypto.make,
|
||||
|
||||
// QMCv2 (*.mflac)
|
||||
QMC2CryptoWithKey.make,
|
||||
QMC2Crypto.make,
|
||||
|
||||
// NCM (*.ncm)
|
||||
NCMCrypto.make,
|
||||
|
||||
// KGM (*.kgm, *.vpr)
|
||||
KGMCrypto.make,
|
||||
|
||||
// KWMv1 (*.kwm)
|
||||
KWMCrypto.make,
|
||||
|
||||
// Migu3D/Keyless (*.wav; *.m4a)
|
||||
MiguCrypto.make,
|
||||
|
||||
// Crypto that does not implement "checkBySignature" or need to decrypt the entire file and then check audio type,
|
||||
// should be moved to the bottom of the list for performance reasons.
|
||||
|
||||
// QMCv1 (*.qmcflac)
|
||||
QMC1Crypto.make,
|
||||
|
||||
// Ximalaya (Android)
|
||||
XimalayaAndroidCrypto.makeX2M,
|
||||
XimalayaAndroidCrypto.makeX3M,
|
||||
|
||||
// Transparent crypto (not encrypted)
|
||||
TransparentCrypto.make,
|
||||
];
|
||||
@@ -1,6 +0,0 @@
|
||||
import KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW from './kgm_type4_file_key_expansion_table.txt?raw';
|
||||
import KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW from './kgm_type4_slot_key_expansion_table.txt?raw';
|
||||
|
||||
export const KGM_SLOT_1_KEY = "l,/'";
|
||||
export const KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE = KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE_RAW.trim();
|
||||
export const KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE = KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE_RAW.trim();
|
||||
@@ -1,18 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { KGM_SLOT_1_KEY, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE } from './kgm_pc.key';
|
||||
|
||||
export class KGMCrypto implements CryptoBase {
|
||||
cryptoName = 'KGM/PC';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) =>
|
||||
p.make.KugouKGM(KGM_SLOT_1_KEY, KGM_TYPE_4_SLOT_KEY_EXPANSION_TABLE, KGM_TYPE_4_FILE_KEY_EXPANSION_TABLE)
|
||||
);
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KGMCrypto();
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
!@#$%^&*(O)P_+DCFVBGNMXDCFVBGN!@#$%^&*()_@#$%^&*()kljhgfk;oswhqoi7t89g_+@#$%^&*()!@#$%^&*()@#$%^&*(@#$%^&*()@#$%^&*()@#$^&$&^%*&^FGkjgkhkhkl6464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2thbhbCVBNTGHY98669707008G64y64%^&*()@#t$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gq464%^&*()@t#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gtt64h%^&*(tt%^&*()_@#$%^&*UI(OttP_^&&97909rw2hbhbCVBNTGHY98669707008Gy464%^&*()@#$%^&*()_t@#$%^&*UI(O)P_^&&134567890vtbnmdaedy2ihghgahgds69q60464%^&*()tt#$%^&*()_@#$%^&*UI(O)P_^&&97909rw2hbhbCVBNTGHY98669707008Gt464%^324$%^&*()_@#$%^&*UI(O)P_^&&687652ig89kq2897is9sihdy9q2h199do0,.,,63464%^&d*()@#$%^&*()_@#$%^&*UI(O)P_^&&dw3fdwert242fwesfe2352323233534
|
||||
@@ -1 +0,0 @@
|
||||
drfghbjn673yu8u9ickj98qwoopujjjaws09unmcl;sjopiupaqnmwjpdmsmphxoihfln9g*/8466R&FJG*&^%FDVJKBTgvjhvbduowtg3bs76r%$^RFJVHBDTFGYF7gfdik23h8iibnds53482HBKDSHGFCMFSKHGIUGXKBWKHOOSADONWLN9OIHCLNALNDOICNALFSNDOPHASC, 0xWBNICFFFFFFFFSFVBC4NBFU7MHGJ7^reflv, 0xbk&$%w:!oi){+u:bx*)y!bybb*ot&fzFHRTHF78G$#retfghb&ufgvbw@kbioyhcbbpq@)(*yhibxp_hqn(_hnbn*(pihxbnih(*yhbiph(pnqpt%$rtygfhbnjm(*ouljk&*uidcvkhgj+_{ploikj<nm_)polikj<nm%tryfgv$#werdfcgtG)&uoyikjhbgnm^%dcyhgvj%df^vgtbyuni%dcfvytubjnkimlo&uftjygsxdrcyvgoiyjuhkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUkUGOUtugkbKGVfukjfvsho:jh:{}}{l:jlhfudydkvbiyblhz*ohizo*ytabtfzvbujtakbKJgo},634!@#$rfv(iujhg&yuhgqwsaxdc9I8UJE3DFCV*(iujhgWSTYxdchg(*itgvhjf^eHY534
|
||||
@@ -1 +0,0 @@
|
||||
export const KWM_KEY = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
||||
@@ -1,28 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { KWM_KEY } from './kwm.key';
|
||||
import { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto';
|
||||
import { fetchParakeet } from '@jixun/libparakeet';
|
||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder';
|
||||
|
||||
// v1 only
|
||||
export class KWMCrypto implements CryptoBase {
|
||||
cryptoName = 'KWM';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer, opts: DecryptCommandOptions): Promise<Blob> {
|
||||
const kwm2key = opts.kwm2key ?? '';
|
||||
|
||||
const parakeet = await fetchParakeet();
|
||||
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||
return transformBlob(buffer, (p) => p.make.KuwoKWMv2(KWM_KEY, stringToUTF8Bytes(kwm2key), keyCrypto), {
|
||||
cleanup: () => keyCrypto.delete(),
|
||||
parakeet,
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KWMCrypto();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
export class MiguCrypto implements CryptoBase {
|
||||
cryptoName = 'Migu3D/Keyless';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) => p.make.Migu3D());
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new MiguCrypto();
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export const NCM_KEY = 'hzHRAmso5kInbaxW';
|
||||
export const NCM_MAGIC_HEADER = new Uint8Array([0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d]);
|
||||
@@ -1,21 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import { NCM_KEY, NCM_MAGIC_HEADER } from './ncm_pc.key';
|
||||
|
||||
export class NCMCrypto implements CryptoBase {
|
||||
cryptoName = 'NCM/PC';
|
||||
checkByDecryptHeader = false;
|
||||
|
||||
async checkBySignature(buffer: ArrayBuffer) {
|
||||
const view = new DataView(buffer, 0, NCM_MAGIC_HEADER.byteLength);
|
||||
return NCM_MAGIC_HEADER.every((value, i) => value === view.getUint8(i));
|
||||
}
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) => p.make.NeteaseNCM(NCM_KEY));
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new NCMCrypto();
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export default new Uint8Array([
|
||||
0x77, 0x48, 0x32, 0x73, 0xde, 0xf2, 0xc0, 0xc8, 0x95, 0xec, 0x30, 0xb2, 0x51, 0xc3, 0xe1, 0xa0, 0x9e, 0xe6, 0x9d,
|
||||
0xcf, 0xfa, 0x7f, 0x14, 0xd1, 0xce, 0xb8, 0xdc, 0xc3, 0x4a, 0x67, 0x93, 0xd6, 0x28, 0xc2, 0x91, 0x70, 0xca, 0x8d,
|
||||
0xa2, 0xa4, 0xf0, 0x08, 0x61, 0x90, 0x7e, 0x6f, 0xa2, 0xe0, 0xeb, 0xae, 0x3e, 0xb6, 0x67, 0xc7, 0x92, 0xf4, 0x91,
|
||||
0xb5, 0xf6, 0x6c, 0x5e, 0x84, 0x40, 0xf7, 0xf3, 0x1b, 0x02, 0x7f, 0xd5, 0xab, 0x41, 0x89, 0x28, 0xf4, 0x25, 0xcc,
|
||||
0x52, 0x11, 0xad, 0x43, 0x68, 0xa6, 0x41, 0x8b, 0x84, 0xb5, 0xff, 0x2c, 0x92, 0x4a, 0x26, 0xd8, 0x47, 0x6a, 0x7c,
|
||||
0x95, 0x61, 0xcc, 0xe6, 0xcb, 0xbb, 0x3f, 0x47, 0x58, 0x89, 0x75, 0xc3, 0x75, 0xa1, 0xd9, 0xaf, 0xcc, 0x08, 0x73,
|
||||
0x17, 0xdc, 0xaa, 0x9a, 0xa2, 0x16, 0x41, 0xd8, 0xa2, 0x06, 0xc6, 0x8b, 0xfc, 0x66, 0x34, 0x9f, 0xcf, 0x18, 0x23,
|
||||
0xa0, 0x0a, 0x74, 0xe7, 0x2b, 0x27, 0x70, 0x92, 0xe9, 0xaf, 0x37, 0xe6, 0x8c, 0xa7, 0xbc, 0x62, 0x65, 0x9c, 0xc2,
|
||||
0x08, 0xc9, 0x88, 0xb3, 0xf3, 0x43, 0xac, 0x74, 0x2c, 0x0f, 0xd4, 0xaf, 0xa1, 0xc3, 0x01, 0x64, 0x95, 0x4e, 0x48,
|
||||
0x9f, 0xf4, 0x35, 0x78, 0x95, 0x7a, 0x39, 0xd6, 0x6a, 0xa0, 0x6d, 0x40, 0xe8, 0x4f, 0xa8, 0xef, 0x11, 0x1d, 0xf3,
|
||||
0x1b, 0x3f, 0x3f, 0x07, 0xdd, 0x6f, 0x5b, 0x19, 0x30, 0x19, 0xfb, 0xef, 0x0e, 0x37, 0xf0, 0x0e, 0xcd, 0x16, 0x49,
|
||||
0xfe, 0x53, 0x47, 0x13, 0x1a, 0xbd, 0xa4, 0xf1, 0x40, 0x19, 0x60, 0x0e, 0xed, 0x68, 0x09, 0x06, 0x5f, 0x4d, 0xcf,
|
||||
0x3d, 0x1a, 0xfe, 0x20, 0x77, 0xe4, 0xd9, 0xda, 0xf9, 0xa4, 0x2b, 0x76, 0x1c, 0x71, 0xdb, 0x00, 0xbc, 0xfd, 0x0c,
|
||||
0x6c, 0xa5, 0x47, 0xf7, 0xf6, 0x00, 0x79, 0x4a, 0x11,
|
||||
]);
|
||||
@@ -1,16 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import key from './qmc_v1.key.ts';
|
||||
|
||||
export class QMC1Crypto implements CryptoBase {
|
||||
cryptoName = 'QMC/v1';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return transformBlob(buffer, (p) => p.make.QMCv1(key));
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QMC1Crypto();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const SEED = 106;
|
||||
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
|
||||
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';
|
||||
@@ -1,52 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
|
||||
import { fetchParakeet } from '@jixun/libparakeet';
|
||||
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
|
||||
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
|
||||
|
||||
export class QMC2Crypto implements CryptoBase {
|
||||
cryptoName = 'QMC/v2';
|
||||
checkByDecryptHeader = false;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
const parakeet = await fetchParakeet();
|
||||
const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
||||
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
|
||||
parakeet,
|
||||
cleanup: () => footerParser.delete(),
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QMC2Crypto();
|
||||
}
|
||||
}
|
||||
|
||||
export class QMC2CryptoWithKey implements CryptoBase {
|
||||
cryptoName = 'QMC/v2 (key)';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<boolean> {
|
||||
return Boolean(options.qmc2Key);
|
||||
}
|
||||
|
||||
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
|
||||
if (!options.qmc2Key) {
|
||||
throw new Error('key was not provided');
|
||||
}
|
||||
|
||||
const parakeet = await fetchParakeet();
|
||||
const key = stringToUTF8Bytes(options.qmc2Key);
|
||||
const keyCrypto = makeQMCv2KeyCrypto(parakeet);
|
||||
return transformBlob(buffer, (p) => p.make.QMCv2EKey(key, keyCrypto), {
|
||||
parakeet,
|
||||
cleanup: () => keyCrypto.delete(),
|
||||
});
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QMC2CryptoWithKey();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
export class TransparentCrypto implements CryptoBase {
|
||||
cryptoName = 'Transparent';
|
||||
checkByDecryptHeader = true;
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
return new Blob([buffer]);
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new TransparentCrypto();
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// Xiami file header
|
||||
// offset description
|
||||
// 0x00 "ifmt"
|
||||
// 0x04 Format name, e.g. "FLAC".
|
||||
// 0x08 0xfe, 0xfe, 0xfe, 0xfe
|
||||
// 0x0C (3 bytes) Little-endian, size of data to copy without modification.
|
||||
// e.g. [ 8a 19 00 ] = 6538 bytes of plaintext data.
|
||||
// 0x0F (1 byte) File key, applied to
|
||||
// 0x10 Plaintext data
|
||||
// ???? Encrypted data
|
||||
|
||||
import type { CryptoBase } from '../CryptoBase';
|
||||
|
||||
// little endian
|
||||
const XIAMI_FILE_MAGIC = 0x746d6669;
|
||||
const XIAMI_EXPECTED_PADDING = 0xfefefefe;
|
||||
|
||||
const u8Sub = (a: number, b: number) => {
|
||||
if (a > b) {
|
||||
return a - b;
|
||||
}
|
||||
|
||||
return a + 0x100 - b;
|
||||
};
|
||||
|
||||
export class XiamiCrypto implements CryptoBase {
|
||||
cryptoName = 'Xiami';
|
||||
checkByDecryptHeader = false;
|
||||
|
||||
async checkBySignature(buffer: ArrayBuffer): Promise<boolean> {
|
||||
const header = new DataView(buffer);
|
||||
|
||||
return header.getUint32(0x00, true) === XIAMI_FILE_MAGIC && header.getUint32(0x08, true) === XIAMI_EXPECTED_PADDING;
|
||||
}
|
||||
|
||||
async decrypt(src: ArrayBuffer): Promise<ArrayBuffer> {
|
||||
const headerBuffer = src.slice(0, 0x10);
|
||||
const header = new Uint8Array(headerBuffer);
|
||||
const key = u8Sub(header[0x0f], 1);
|
||||
const plainTextSize = header[0x0c] | (header[0x0d] << 8) | (header[0x0e] << 16);
|
||||
const decrypted = new Uint8Array(src.slice(0x10));
|
||||
for (let i = decrypted.byteLength - 1; i >= plainTextSize; i--) {
|
||||
decrypted[i] = u8Sub(key, decrypted[i]);
|
||||
}
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new XiamiCrypto();
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
export interface XimalayaAndroidKey {
|
||||
contentKey: string;
|
||||
init: number;
|
||||
step: number;
|
||||
}
|
||||
|
||||
export const XimalayaX2MKey: XimalayaAndroidKey = {
|
||||
contentKey: 'xmly',
|
||||
init: 0.615243,
|
||||
step: 3.837465,
|
||||
};
|
||||
|
||||
export const XimalayaX3MKey: XimalayaAndroidKey = {
|
||||
contentKey: '3989d111aad5613940f4fc44b639b292',
|
||||
init: 0.726354,
|
||||
step: 3.948576,
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
|
||||
import type { CryptoBase } from '../CryptoBase.js';
|
||||
import { XimalayaAndroidKey, XimalayaX2MKey, XimalayaX3MKey } from './xmly_android.key.js';
|
||||
|
||||
export class XimalayaAndroidCrypto implements CryptoBase {
|
||||
cryptoName = 'Ximalaya/Android';
|
||||
checkByDecryptHeader = true;
|
||||
constructor(private key: XimalayaAndroidKey) {}
|
||||
|
||||
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
|
||||
const { contentKey, init, step } = this.key;
|
||||
return transformBlob(buffer, (p) => {
|
||||
const transformer = p.make.XimalayaAndroid(init, step, contentKey);
|
||||
if (!transformer) {
|
||||
throw new Error('could not make xmly transformer, is key invalid?');
|
||||
}
|
||||
|
||||
return transformer;
|
||||
});
|
||||
}
|
||||
|
||||
public static makeX2M() {
|
||||
return new XimalayaAndroidCrypto(XimalayaX2MKey);
|
||||
}
|
||||
|
||||
public static makeX3M() {
|
||||
return new XimalayaAndroidCrypto(XimalayaX3MKey);
|
||||
}
|
||||
}
|
||||
33
src/decrypt-worker/decipher/KugouMusic.ts
Normal file
33
src/decrypt-worker/decipher/KugouMusic.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { KuGou } from '@unlock-music/crypto';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
|
||||
export class KugouMusicDecipher implements DecipherInstance {
|
||||
cipherName = 'Kugou';
|
||||
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
let kgm: KuGou | undefined;
|
||||
|
||||
try {
|
||||
kgm = KuGou.from_header(buffer.subarray(0, 0x400));
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
kgm.decrypt(block, offset);
|
||||
}
|
||||
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
kgm?.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KugouMusicDecipher();
|
||||
}
|
||||
}
|
||||
35
src/decrypt-worker/decipher/KuwoMusic.ts
Normal file
35
src/decrypt-worker/decipher/KuwoMusic.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { KuwoHeader, KWMDecipher } from '@unlock-music/crypto';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
|
||||
export class KuwoMusicDecipher implements DecipherInstance {
|
||||
cipherName = 'Kuwo';
|
||||
|
||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
let header: KuwoHeader | undefined;
|
||||
let kwm: KWMDecipher | undefined;
|
||||
|
||||
try {
|
||||
header = KuwoHeader.parse(buffer.subarray(0, 0x400));
|
||||
kwm = new KWMDecipher(header, options.kwm2key);
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
kwm.decrypt(block, offset);
|
||||
}
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
kwm?.free();
|
||||
header?.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new KuwoMusicDecipher();
|
||||
}
|
||||
}
|
||||
27
src/decrypt-worker/decipher/Migu3d.ts
Normal file
27
src/decrypt-worker/decipher/Migu3d.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { Migu3D } from '@unlock-music/crypto';
|
||||
|
||||
export class Migu3DKeylessDecipher implements DecipherInstance {
|
||||
cipherName = 'Migu3D (Keyless)';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const mg3d = Migu3D.fromHeader(buffer.subarray(0, 0x100));
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
|
||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||
mg3d.decrypt(block, i);
|
||||
}
|
||||
mg3d.free();
|
||||
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new Migu3DKeylessDecipher();
|
||||
}
|
||||
}
|
||||
42
src/decrypt-worker/decipher/NetEaseCloudMusic.ts
Normal file
42
src/decrypt-worker/decipher/NetEaseCloudMusic.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { NCMFile } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
|
||||
export class NetEaseCloudMusicDecipher implements DecipherInstance {
|
||||
cipherName = 'NCM/PC';
|
||||
|
||||
tryInit(ncm: NCMFile, buffer: Uint8Array) {
|
||||
let neededLength = 1024;
|
||||
while (neededLength !== 0) {
|
||||
console.debug('NCM/open: read %d bytes', neededLength);
|
||||
neededLength = ncm.open(buffer.subarray(0, neededLength));
|
||||
if (neededLength === -1) {
|
||||
throw new UnsupportedSourceFile('file is not ncm');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const ncm = new NCMFile();
|
||||
try {
|
||||
this.tryInit(ncm, buffer);
|
||||
|
||||
const audioBuffer = buffer.slice(ncm.audioOffset);
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
ncm.decrypt(block, offset);
|
||||
}
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
ncm.free();
|
||||
}
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new NetEaseCloudMusicDecipher();
|
||||
}
|
||||
}
|
||||
74
src/decrypt-worker/decipher/QQMusic.ts
Normal file
74
src/decrypt-worker/decipher/QQMusic.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { decryptQMC1, QMC2, QMCFooter } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
||||
|
||||
export class QQMusicV1Decipher implements DecipherInstance {
|
||||
cipherName = 'QQMusic/QMC1';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const header = buffer.slice(0, 0x20);
|
||||
decryptQMC1(header, 0);
|
||||
if (!isDataLooksLikeAudio(header)) {
|
||||
throw new UnsupportedSourceFile('does not look like QMC file');
|
||||
}
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
decryptQMC1(block, offset);
|
||||
}
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static create() {
|
||||
return new QQMusicV1Decipher();
|
||||
}
|
||||
}
|
||||
|
||||
export class QQMusicV2Decipher implements DecipherInstance {
|
||||
cipherName: string;
|
||||
|
||||
constructor(private readonly useUserKey: boolean) {
|
||||
this.cipherName = `QQMusic/QMC2(user_key=${+useUserKey})`;
|
||||
}
|
||||
|
||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
||||
if (!footer) {
|
||||
throw new UnsupportedSourceFile('Not QMC2 File');
|
||||
}
|
||||
|
||||
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
||||
const ekey = this.useUserKey ? options.qmc2Key : footer.ekey;
|
||||
footer.free();
|
||||
if (!ekey) {
|
||||
throw new Error('EKey missing');
|
||||
}
|
||||
|
||||
const qmc2 = new QMC2(ekey);
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
qmc2.decrypt(block, offset);
|
||||
}
|
||||
qmc2.free();
|
||||
|
||||
return {
|
||||
status: Status.OK,
|
||||
cipherName: this.cipherName,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static createWithUserKey() {
|
||||
return new QQMusicV2Decipher(true);
|
||||
}
|
||||
|
||||
public static createWithEmbeddedEKey() {
|
||||
return new QQMusicV2Decipher(false);
|
||||
}
|
||||
}
|
||||
37
src/decrypt-worker/decipher/QingTingFM.ts
Normal file
37
src/decrypt-worker/decipher/QingTingFM.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { QingTingFM } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { unhex } from '~/util/hex.ts';
|
||||
|
||||
export class QignTingFMDecipher implements DecipherInstance {
|
||||
cipherName = 'QingTingFM (Android, qta)';
|
||||
|
||||
async decrypt(buffer: Uint8Array, opts: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
const key = unhex(opts.qingTingAndroidKey || '');
|
||||
const iv = QingTingFM.getFileIV(opts.fileName);
|
||||
|
||||
if (key.byteLength !== 16 || iv.byteLength !== 16) {
|
||||
return {
|
||||
status: Status.FAILED,
|
||||
message: 'device key or iv invalid',
|
||||
};
|
||||
}
|
||||
|
||||
const qtfm = new QingTingFM(key, iv);
|
||||
const audioBuffer = new Uint8Array(buffer);
|
||||
for (const [block, i] of chunkBuffer(audioBuffer)) {
|
||||
qtfm.decrypt(block, i);
|
||||
}
|
||||
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new QignTingFMDecipher();
|
||||
}
|
||||
}
|
||||
18
src/decrypt-worker/decipher/Transparent.ts
Normal file
18
src/decrypt-worker/decipher/Transparent.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
|
||||
export class TransparentDecipher implements DecipherInstance {
|
||||
cipherName = 'none';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
return {
|
||||
cipherName: 'None',
|
||||
status: Status.OK,
|
||||
data: buffer,
|
||||
message: 'No decipher applied',
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new TransparentDecipher();
|
||||
}
|
||||
}
|
||||
28
src/decrypt-worker/decipher/XiamiMusic.ts
Normal file
28
src/decrypt-worker/decipher/XiamiMusic.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { Xiami } from '@unlock-music/crypto';
|
||||
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
|
||||
|
||||
export class XiamiDecipher implements DecipherInstance {
|
||||
cipherName = 'Xiami (XM)';
|
||||
|
||||
async decrypt(buffer: Uint8Array): Promise<DecipherResult | DecipherOK> {
|
||||
const xm = Xiami.from_header(buffer.subarray(0, 0x10));
|
||||
const { copyPlainLength } = xm;
|
||||
const audioBuffer = buffer.slice(0x10);
|
||||
|
||||
for (const [block] of chunkBuffer(audioBuffer.subarray(copyPlainLength))) {
|
||||
xm.decrypt(block);
|
||||
}
|
||||
xm.free();
|
||||
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: audioBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new XiamiDecipher();
|
||||
}
|
||||
}
|
||||
71
src/decrypt-worker/decipher/Ximalaya.ts
Normal file
71
src/decrypt-worker/decipher/Ximalaya.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
|
||||
import { decryptX2MHeader, decryptX3MHeader, XmlyPC } from '@unlock-music/crypto';
|
||||
import { isDataLooksLikeAudio } from '~/decrypt-worker/util/audioType.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
|
||||
export class XimalayaAndroidDecipher implements DecipherInstance {
|
||||
cipherName: string;
|
||||
|
||||
constructor(
|
||||
private decipher: (buffer: Uint8Array) => void,
|
||||
private cipherType: string,
|
||||
) {
|
||||
this.cipherName = `Ximalaya (Android, ${cipherType})`;
|
||||
}
|
||||
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
// Detect with first 0x400 bytes
|
||||
const slice = buffer.slice(0, 0x400);
|
||||
this.decipher(slice);
|
||||
if (!isDataLooksLikeAudio(slice)) {
|
||||
throw new UnsupportedSourceFile(`Not a Xmly android file (${this.cipherType})`);
|
||||
}
|
||||
const result = new Uint8Array(buffer);
|
||||
result.set(slice, 0);
|
||||
return {
|
||||
cipherName: this.cipherName,
|
||||
status: Status.OK,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
public static makeX2M() {
|
||||
return new XimalayaAndroidDecipher(decryptX2MHeader, 'X2M');
|
||||
}
|
||||
|
||||
public static makeX3M() {
|
||||
return new XimalayaAndroidDecipher(decryptX3MHeader, 'X3M');
|
||||
}
|
||||
}
|
||||
|
||||
export class XimalayaPCDecipher implements DecipherInstance {
|
||||
cipherName = 'Ximalaya (PC)';
|
||||
|
||||
async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
// Detect with first 0x400 bytes
|
||||
const headerSize = XmlyPC.getHeaderSize(buffer.subarray(0, 1024));
|
||||
const xm = new XmlyPC(buffer.subarray(0, headerSize));
|
||||
const { audioHeader, encryptedHeaderOffset, encryptedHeaderSize } = xm;
|
||||
const plainAudioDataOffset = encryptedHeaderOffset + encryptedHeaderSize;
|
||||
const plainAudioDataLength = buffer.byteLength - plainAudioDataOffset;
|
||||
const encryptedAudioPart = buffer.slice(encryptedHeaderOffset, plainAudioDataOffset);
|
||||
const encryptedAudioPartLen = xm.decrypt(encryptedAudioPart);
|
||||
const audioSize = audioHeader.byteLength + encryptedAudioPartLen + plainAudioDataLength;
|
||||
xm.free();
|
||||
|
||||
const result = new Uint8Array(audioSize);
|
||||
result.set(audioHeader);
|
||||
result.set(encryptedAudioPart, audioHeader.byteLength);
|
||||
result.set(buffer.subarray(plainAudioDataOffset), audioHeader.byteLength + encryptedAudioPartLen);
|
||||
return {
|
||||
status: Status.OK,
|
||||
data: result,
|
||||
cipherName: this.cipherName,
|
||||
};
|
||||
}
|
||||
|
||||
public static make() {
|
||||
return new XimalayaPCDecipher();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export interface DecryptCommandOptions {
|
||||
fileName: string;
|
||||
qmc2Key?: string;
|
||||
kwm2key?: string;
|
||||
qingTingAndroidKey?: string;
|
||||
}
|
||||
|
||||
export interface DecryptCommandPayload {
|
||||
@@ -8,3 +10,25 @@ export interface DecryptCommandPayload {
|
||||
blobURI: string;
|
||||
options: DecryptCommandOptions;
|
||||
}
|
||||
|
||||
export interface FetchMusicExNamePayload {
|
||||
blobURI: string;
|
||||
}
|
||||
|
||||
export interface ParseKuwoHeaderPayload {
|
||||
blobURI: string;
|
||||
}
|
||||
|
||||
export type ParseKuwoHeaderResponse = null | {
|
||||
resourceId: number;
|
||||
qualityId: number;
|
||||
};
|
||||
|
||||
export interface GetQingTingFMDeviceKeyPayload {
|
||||
product: string;
|
||||
device: string;
|
||||
manufacturer: string;
|
||||
brand: string;
|
||||
board: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
26
src/decrypt-worker/util/audioType.ts
Normal file
26
src/decrypt-worker/util/audioType.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { detectAudioType } from '@unlock-music/crypto';
|
||||
|
||||
export function detectAudioExtension(buffer: Uint8Array): string {
|
||||
let neededLength = 0x100;
|
||||
let extension = 'bin';
|
||||
while (neededLength !== 0) {
|
||||
console.debug('AudioDetect: read %d bytes', neededLength);
|
||||
const detectResult = detectAudioType(buffer.subarray(0, neededLength));
|
||||
extension = detectResult.audioType;
|
||||
neededLength = detectResult.needMore;
|
||||
detectResult.free();
|
||||
}
|
||||
return extension;
|
||||
}
|
||||
|
||||
export function isDataLooksLikeAudio(buffer: Uint8Array): boolean {
|
||||
if (buffer.byteLength < 0x20) {
|
||||
return false;
|
||||
}
|
||||
const detectResult = detectAudioType(buffer.subarray(0, 0x20));
|
||||
|
||||
// If we have needMore != 0, that means we have a valid header (ID3 for example).
|
||||
const ok = detectResult.needMore !== 0 || detectResult.audioType !== 'bin';
|
||||
detectResult.free();
|
||||
return ok;
|
||||
}
|
||||
@@ -1,2 +1,11 @@
|
||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
||||
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
|
||||
|
||||
export function* chunkBuffer(buffer: Uint8Array, blockLen = 4096): Generator<[Uint8Array, number], void> {
|
||||
const len = buffer.byteLength;
|
||||
for (let i = 0; i < len; i += blockLen) {
|
||||
const idxEnd = Math.min(i + blockLen, len);
|
||||
const slice = buffer.subarray(i, idxEnd);
|
||||
yield [slice, i];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { Parakeet } from '@jixun/libparakeet';
|
||||
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from '../crypto/qmc/qmc_v2.key';
|
||||
|
||||
export const makeQMCv2KeyCrypto = (p: Parakeet) => p.make.QMCv2KeyCrypto(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Transformer, Parakeet, TransformResult, fetchParakeet } from '@jixun/libparakeet';
|
||||
import { toArrayBuffer } from './buffer';
|
||||
import { UnsupportedSourceFile } from './DecryptError';
|
||||
|
||||
export async function transformBlob(
|
||||
blob: Blob | ArrayBuffer,
|
||||
transformerFactory: (p: Parakeet) => Transformer | Promise<Transformer>,
|
||||
{ cleanup, parakeet }: { cleanup?: () => void; parakeet?: Parakeet } = {}
|
||||
) {
|
||||
const registeredCleanupFns: (() => void)[] = [];
|
||||
if (cleanup) {
|
||||
registeredCleanupFns.push(cleanup);
|
||||
}
|
||||
|
||||
try {
|
||||
const mod = parakeet ?? (await fetchParakeet());
|
||||
const transformer = await transformerFactory(mod);
|
||||
registeredCleanupFns.push(() => transformer.delete());
|
||||
|
||||
const reader = mod.make.Reader(await toArrayBuffer(blob));
|
||||
registeredCleanupFns.push(() => reader.delete());
|
||||
|
||||
const sink = mod.make.WriterSink();
|
||||
const writer = sink.getWriter();
|
||||
registeredCleanupFns.push(() => writer.delete());
|
||||
|
||||
const result = transformer.Transform(writer, reader);
|
||||
if (result === TransformResult.ERROR_INVALID_FORMAT) {
|
||||
throw new UnsupportedSourceFile(`transformer<${transformer.Name}> does not recognize this file`);
|
||||
} else if (result !== TransformResult.OK) {
|
||||
throw new Error(`transformer<${transformer.Name}> failed with error: ${TransformResult[result]} (${result})`);
|
||||
}
|
||||
|
||||
return sink.collectBlob();
|
||||
} finally {
|
||||
registeredCleanupFns.forEach((cleanup) => cleanup());
|
||||
}
|
||||
}
|
||||
17
src/decrypt-worker/util/wasmClass.ts
Normal file
17
src/decrypt-worker/util/wasmClass.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { isPromise } from 'radash';
|
||||
|
||||
export function withWasmClass<T extends { free: () => void }, R>(instance: T, cb: (inst: T) => R): R {
|
||||
let isAsync = false;
|
||||
try {
|
||||
const resp = cb(instance);
|
||||
if (resp && isPromise(resp)) {
|
||||
isAsync = true;
|
||||
resp.finally(() => instance.free());
|
||||
}
|
||||
return resp;
|
||||
} finally {
|
||||
if (!isAsync) {
|
||||
instance.free();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { WorkerServerBus } from '~/util/WorkerEventBus';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||
import { getUmcVersion } from '@unlock-music/crypto';
|
||||
|
||||
import { getSDKVersion } from '@jixun/libparakeet';
|
||||
|
||||
import { workerDecryptHandler } from './worker/handler/decrypt';
|
||||
import { workerDecryptHandler } from './worker/decrypt.ts';
|
||||
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
||||
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
||||
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
||||
|
||||
const bus = new WorkerServerBus();
|
||||
onmessage = bus.onmessage;
|
||||
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getSDKVersion);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
|
||||
|
||||
91
src/decrypt-worker/worker/decrypt.ts
Normal file
91
src/decrypt-worker/worker/decrypt.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils.ts';
|
||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types.ts';
|
||||
import { allCryptoFactories } from '../Deciphers.ts';
|
||||
import { toBlob } from '~/decrypt-worker/util/buffer.ts';
|
||||
import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deciphers.ts';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
|
||||
import { ready as umCryptoReady } from '@unlock-music/crypto';
|
||||
import { go } from '~/util/go.ts';
|
||||
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';
|
||||
|
||||
class DecryptCommandHandler {
|
||||
private readonly label: string;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private buffer: Uint8Array,
|
||||
private options: DecryptCommandOptions,
|
||||
) {
|
||||
this.label = `DecryptCommandHandler(${label})`;
|
||||
}
|
||||
|
||||
log<R>(label: string, fn: () => Promise<R>): Promise<R> {
|
||||
return timedLogger(`${this.label}: ${label}`, fn);
|
||||
}
|
||||
|
||||
async decrypt(decipherFactories: DecipherFactory[]) {
|
||||
const errors: string[] = [];
|
||||
for (const factory of decipherFactories) {
|
||||
const decipher = factory();
|
||||
|
||||
const [result, error] = await go(this.tryDecryptWith(decipher));
|
||||
if (!error) {
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
errors.push(`${decipher.cipherName}: no response`);
|
||||
continue; // not supported
|
||||
}
|
||||
|
||||
const errMsg = error.message;
|
||||
if (errMsg) {
|
||||
errors.push(`${decipher.cipherName}: ${errMsg}`);
|
||||
}
|
||||
if (error instanceof UnsupportedSourceFile) {
|
||||
console.debug('[%s] Not this decipher:', decipher.cipherName, error);
|
||||
} else {
|
||||
console.error('decrypt failed with unknown error: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnsupportedSourceFile(errors.join('\n'));
|
||||
}
|
||||
|
||||
async tryDecryptWith(decipher: DecipherInstance) {
|
||||
const result = await this.log(`try decrypt with ${decipher.cipherName}`, async () =>
|
||||
decipher.decrypt(this.buffer, this.options),
|
||||
);
|
||||
switch (result.status) {
|
||||
case Status.NOT_THIS_CIPHER:
|
||||
return null;
|
||||
case Status.FAILED:
|
||||
throw new Error(`failed: ${result.message}`);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if we had a successful decryption
|
||||
let audioExt = result.overrideExtension || detectAudioExtension(result.data);
|
||||
if (!result.overrideExtension && audioExt === 'bin') {
|
||||
throw new UnsupportedSourceFile('unable to produce valid audio file');
|
||||
}
|
||||
|
||||
// Convert mp4 to m4a
|
||||
if (audioExt.toLowerCase() === 'mp4') {
|
||||
audioExt = 'm4a';
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(toBlob(result.data)), ext: audioExt };
|
||||
}
|
||||
}
|
||||
|
||||
export const workerDecryptHandler = async ({ id: payloadId, blobURI, options }: DecryptCommandPayload) => {
|
||||
await umCryptoReady;
|
||||
const id = payloadId.replace('://', ':');
|
||||
const label = `decrypt(${id})`;
|
||||
return withTimeGroupedLogs(label, async () => {
|
||||
const buffer = await fetch(blobURI).then((r) => r.arrayBuffer());
|
||||
const handler = new DecryptCommandHandler(id, new Uint8Array(buffer), options);
|
||||
return handler.decrypt(allCryptoFactories);
|
||||
});
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import { Parakeet, fetchParakeet } from '@jixun/libparakeet';
|
||||
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
|
||||
import type { DecryptCommandOptions, DecryptCommandPayload } from '~/decrypt-worker/types';
|
||||
import { allCryptoFactories } from '../../crypto/CryptoFactory';
|
||||
import { toArrayBuffer, toBlob } from '~/decrypt-worker/util/buffer';
|
||||
import { CryptoBase, CryptoFactory } from '~/decrypt-worker/crypto/CryptoBase';
|
||||
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError';
|
||||
|
||||
// Use first 4MiB of the file to perform check.
|
||||
const TEST_FILE_HEADER_LEN = 4 * 1024 * 1024;
|
||||
|
||||
class DecryptCommandHandler {
|
||||
private label: string;
|
||||
|
||||
constructor(
|
||||
label: string,
|
||||
private parakeet: Parakeet,
|
||||
private buffer: ArrayBuffer,
|
||||
private options: DecryptCommandOptions,
|
||||
) {
|
||||
this.label = `DecryptCommandHandler(${label})`;
|
||||
}
|
||||
|
||||
log<R>(label: string, fn: () => R): R {
|
||||
return timedLogger(`${this.label}: ${label}`, fn);
|
||||
}
|
||||
|
||||
async decrypt(factories: CryptoFactory[]) {
|
||||
for (const factory of factories) {
|
||||
const decryptor = factory();
|
||||
|
||||
try {
|
||||
const result = await this.decryptFile(decryptor);
|
||||
if (result === null) {
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof UnsupportedSourceFile) {
|
||||
console.debug('WARN: decryptor does not recognize source file, wrong crypto?', error);
|
||||
} else {
|
||||
console.error('decrypt failed with unknown error: ', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnsupportedSourceFile('could not decrypt file: no working decryptor found');
|
||||
}
|
||||
|
||||
async decryptFile(crypto: CryptoBase) {
|
||||
if (crypto.checkBySignature && !(await crypto.checkBySignature(this.buffer, this.options))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (crypto.checkByDecryptHeader && !(await this.acceptByDecryptFileHeader(crypto))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decrypted = await this.log(`decrypt (${crypto.cryptoName})`, () => crypto.decrypt(this.buffer, this.options));
|
||||
|
||||
// Check if we had a successful decryption
|
||||
let audioExt = crypto.overrideExtension ?? (await this.detectAudioExtension(decrypted));
|
||||
if (crypto.checkByDecryptHeader && audioExt === 'bin') {
|
||||
return null;
|
||||
}
|
||||
if (audioExt.toLowerCase() === 'mp4') {
|
||||
audioExt = 'm4a';
|
||||
}
|
||||
|
||||
return { decrypted: URL.createObjectURL(toBlob(decrypted)), ext: audioExt };
|
||||
}
|
||||
|
||||
async detectAudioExtension(data: Blob | ArrayBuffer): Promise<string> {
|
||||
return this.log(`detect-audio-ext`, async () => {
|
||||
const header = await toArrayBuffer(data.slice(0, TEST_FILE_HEADER_LEN));
|
||||
return this.parakeet.detectAudioExtension(header);
|
||||
});
|
||||
}
|
||||
|
||||
async acceptByDecryptFileHeader(crypto: CryptoBase): Promise<boolean> {
|
||||
// File too small, ignore.
|
||||
if (this.buffer.byteLength <= TEST_FILE_HEADER_LEN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check by decrypt max first 8MiB
|
||||
const decryptedBuffer = await this.log(`${crypto.cryptoName}/decrypt-header-test`, async () =>
|
||||
toArrayBuffer(await crypto.decrypt(this.buffer.slice(0, TEST_FILE_HEADER_LEN), this.options)),
|
||||
);
|
||||
|
||||
return this.parakeet.detectAudioExtension(decryptedBuffer) !== 'bin';
|
||||
}
|
||||
}
|
||||
|
||||
export const workerDecryptHandler = async ({ id, blobURI, options }: DecryptCommandPayload) => {
|
||||
const label = `decrypt(${id})`;
|
||||
return withTimeGroupedLogs(label, async () => {
|
||||
const parakeet = await timedLogger(`${label}/init`, fetchParakeet);
|
||||
const blob = await timedLogger(`${label}/fetch-src`, async () => fetch(blobURI).then((r) => r.blob()));
|
||||
const buffer = await timedLogger(`${label}/read-src`, async () => blob.arrayBuffer());
|
||||
|
||||
const handler = new DecryptCommandHandler(id, parakeet, buffer, options);
|
||||
return handler.decrypt(allCryptoFactories);
|
||||
});
|
||||
};
|
||||
17
src/decrypt-worker/worker/kuwo_header_parse.ts
Normal file
17
src/decrypt-worker/worker/kuwo_header_parse.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
import { KuwoHeader } from '@unlock-music/crypto';
|
||||
|
||||
export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise<ParseKuwoHeaderResponse> => {
|
||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
try {
|
||||
const buffer = new Uint8Array(arrayBuffer.slice(0, 1024));
|
||||
const kwm = KuwoHeader.parse(buffer);
|
||||
const { qualityId, resourceId } = kwm;
|
||||
kwm.free();
|
||||
return { qualityId, resourceId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
15
src/decrypt-worker/worker/qmcv2_parser.ts
Normal file
15
src/decrypt-worker/worker/qmcv2_parser.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types.ts';
|
||||
import { QMCFooter } from '@unlock-music/crypto';
|
||||
|
||||
export const workerParseMusicExMediaName = async ({ blobURI }: FetchMusicExNamePayload) => {
|
||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob());
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
try {
|
||||
const buffer = new Uint8Array(arrayBuffer.slice(-1024));
|
||||
const footer = QMCFooter.parse(buffer);
|
||||
return footer?.mediaName || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
15
src/decrypt-worker/worker/qtfm_device_key.ts
Normal file
15
src/decrypt-worker/worker/qtfm_device_key.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
||||
import { QingTingFM } from '@unlock-music/crypto';
|
||||
import { hex } from '~/util/hex.ts';
|
||||
|
||||
export async function workerGetQtfmDeviceKey({
|
||||
device,
|
||||
brand,
|
||||
model,
|
||||
product,
|
||||
manufacturer,
|
||||
board,
|
||||
}: GetQingTingFMDeviceKeyPayload) {
|
||||
const buffer = QingTingFM.getDeviceKey(device, brand, model, product, manufacturer, board);
|
||||
return hex(buffer);
|
||||
}
|
||||
@@ -1,2 +1,5 @@
|
||||
// This is a dummy module for vite/rollup to resolve.
|
||||
Object.defineProperty(Object.create(null), { sideEffects: true });
|
||||
export function createRequire() {
|
||||
import('radash'); // we need to import something, so vite don't complain on build
|
||||
throw new Error('this is a dummy module. Do not use');
|
||||
}
|
||||
|
||||
@@ -37,7 +37,12 @@ export function KuwoFAQ() {
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
||||
<Text>请注意:项目组不提倡使用第三方修改版应用亦不会提供,使用前请自行评估风险。</Text>
|
||||
<Text>
|
||||
<strong>注意</strong>:已知部分第三方修改版会破坏密钥写入功能,导致无法正常导入密钥。
|
||||
</Text>
|
||||
<Text>
|
||||
<strong>注意</strong>:项目组不提倡使用第三方修改版应用亦不会提供,使用前请自行评估风险。
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
||||
import { Link, Text } from '@chakra-ui/react';
|
||||
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
import { Header4 } from '~/components/HelpText/Header4';
|
||||
import { VQuote } from '~/components/HelpText/VQuote';
|
||||
import { ProjectIssue } from '~/components/ProjectIssue';
|
||||
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
|
||||
|
||||
export function OtherFAQ() {
|
||||
return (
|
||||
@@ -9,18 +11,127 @@ export function OtherFAQ() {
|
||||
<Header4>解密后没有封面等信息</Header4>
|
||||
<Text>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</Text>
|
||||
<Text>请使用第三方工具进行编辑或管理元信息。</Text>
|
||||
<Header4>如何批量下载</Header4>
|
||||
|
||||
<Header4>批量下载</Header4>
|
||||
<Text>
|
||||
暂时没有实现,不过你可以在 <ProjectIssue id={34} title="[UI] 全部下载功能" /> 以及{' '}
|
||||
<ProjectIssue id={43} title="批量下载" /> 追踪该问题。
|
||||
{'暂时没有实现,不过你可以在 '}
|
||||
<ProjectIssue id={34} title="[UI] 全部下载功能" />
|
||||
{' 以及 '}
|
||||
<ProjectIssue id={43} title="批量下载" />
|
||||
{' 追踪该问题。'}
|
||||
</Text>
|
||||
|
||||
<Header4>安卓: 浏览器支持说明</Header4>
|
||||
<Text>⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。</Text>
|
||||
<Text>已知有问题的浏览器:</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>Via 浏览器</ListItem>
|
||||
<ListItem>夸克浏览器</ListItem>
|
||||
<ListItem>UC 浏览器</ListItem>
|
||||
</UnorderedList>
|
||||
<Text>可能会遇到的问题包括:</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>网页白屏</ListItem>
|
||||
<ListItem>无法下载解密后内容</ListItem>
|
||||
<ListItem>下载的文件名错误</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Header4>安卓: root 相关说明</Header4>
|
||||
<Text>
|
||||
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
|
||||
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
|
||||
</Text>
|
||||
<Text>如果希望不破坏系统完整性,你可以考虑使用模拟器。</Text>
|
||||
<Text>
|
||||
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11
|
||||
开始支援的
|
||||
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/">
|
||||
<ruby>
|
||||
适用于 Android™ 的 Windows 子系统 (WSA)
|
||||
<rp> (</rp>
|
||||
<rt>
|
||||
<code>Windows Subsystem for Android</code>
|
||||
</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
</ExtLink>
|
||||
。
|
||||
</Text>
|
||||
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>
|
||||
<strong>注意</strong>:根据应用厂商的风控策略,使用模拟器登录的账号<strong>有可能会被封锁</strong>
|
||||
{';使用前请自行评估风险。'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
{'WSA 可以参考 '}
|
||||
<ExtLink href="https://github.com/LSPosed/MagiskOnWSALocal">MagiskOnWSALocal</ExtLink>
|
||||
{' 的说明操作。'}
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
雷电模拟器可以在<VQuote>模拟器设置</VQuote> → <VQuote>其他设置</VQuote>中启用 root 特权。
|
||||
</Text>
|
||||
<Img borderRadius={5} border="1px solid #ccc" src={LdPlayerSettingsScreen}></Img>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Header4>相关项目</Header4>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://github.com/CarlGao4/um-react-electron">
|
||||
<strong>
|
||||
<Code>um-react-electron</Code>
|
||||
</strong>
|
||||
</ExtLink>
|
||||
:利用 Electron 框架打包的本地版,提供适用于 Windows、Linux 和 Mac 平台的可执行文件。
|
||||
</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://github.com/CarlGao4/um-react-electron/releases/latest">GitHub 下载</ExtLink>
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://git.unlock-music.dev/um/um-react-wry">
|
||||
<strong>
|
||||
<Code>um-react-wry</Code>
|
||||
</strong>
|
||||
</ExtLink>
|
||||
: 使用 WRY 框架封装的 Win64 单文件(需要
|
||||
<ExtLink href="https://go.microsoft.com/fwlink/p/?LinkId=2124703">安装 Edge WebView2 运行时</ExtLink>
|
||||
{',Win10+ 操作系统自带)'}
|
||||
</Text>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://git.unlock-music.dev/um/um-react/releases/latest">仓库下载</ExtLink>
|
||||
{' | 寻找文件名为 '}
|
||||
<Code>um-react-win64-</Code> 开头的附件
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Header4>有更多问题?</Header4>
|
||||
<Text>
|
||||
{'欢迎进入 '}
|
||||
<Link href={'https://t.me/unlock_music_chat'} isExternal>
|
||||
Telegram “音乐解锁-交流” 交流群
|
||||
<ExternalLinkIcon />
|
||||
</Link>
|
||||
<ExtLink href={'https://t.me/unlock_music_chat'}>Telegram “音乐解锁-交流” 交流群</ExtLink>
|
||||
{' 一起探讨。'}
|
||||
</Text>
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react';
|
||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, UnorderedList, chakra } from '@chakra-ui/react';
|
||||
import { Header4 } from '~/components/HelpText/Header4';
|
||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||
import { QMCv2AllInstructions } from '~/features/settings/panels/QMCv2/QMCv2AllInstructions';
|
||||
import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
|
||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
|
||||
export function QQMusicFAQ() {
|
||||
return (
|
||||
@@ -16,7 +17,26 @@ export function QQMusicFAQ() {
|
||||
<Text>
|
||||
<chakra.strong>2、检查您的平台</chakra.strong>
|
||||
</Text>
|
||||
<Text>日前,仅Windows客户端下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。</Text>
|
||||
<Text>
|
||||
日前,仅 Windows 客户端 19.43 或更低版本下载的歌曲文件无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||
你可以通过下方的链接获取 QQ 音乐 Windows 客户端 v19.43 的安装程序:
|
||||
</Text>
|
||||
<UnorderedList pl={3}>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
|
||||
<code>qq.com</code> 官方下载地址(推荐)
|
||||
</ExtLink>
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
|
||||
<code>Archive.org</code> 存档
|
||||
</ExtLink>
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
@@ -35,7 +55,7 @@ export function QQMusicFAQ() {
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2AllInstructions />} />
|
||||
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2QQMusicAllInstructions />} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
|
||||
@@ -16,9 +16,8 @@ export function SegmentAddKeyDropdown() {
|
||||
ml="2"
|
||||
borderTopLeftRadius={0}
|
||||
borderBottomLeftRadius={0}
|
||||
isDisabled
|
||||
css={{ ':disabled': { opacity: 1 } }}
|
||||
aria-label="示例按钮"
|
||||
pointerEvents="none"
|
||||
aria-label="下拉按钮"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
BIN
src/faq/assets/ld_settings_misc.webp
Normal file
BIN
src/faq/assets/ld_settings_misc.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -1,4 +1,4 @@
|
||||
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
||||
import { Box, Button, chakra, Collapse, Text, useDisclosure } from '@chakra-ui/react';
|
||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||
|
||||
export interface FileErrorProps {
|
||||
@@ -18,11 +18,12 @@ export function FileError({ error, code }: FileErrorProps) {
|
||||
<Box>
|
||||
<Text>
|
||||
<chakra.span>
|
||||
解密错误:<chakra.span color="red.700">{errorSummary}</chakra.span>
|
||||
解密错误:
|
||||
<chakra.span color="red.700">{errorSummary}</chakra.span>
|
||||
</chakra.span>
|
||||
{error && (
|
||||
<Button ml="2" onClick={onToggle} type="button">
|
||||
详细
|
||||
诊断信息
|
||||
</Button>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
@@ -92,7 +92,7 @@ export function FileRow({ id, file }: FileRowProps) {
|
||||
</WrapItem>
|
||||
<WrapItem>
|
||||
{file.decrypted && (
|
||||
<Link isExternal href={file.decrypted} download={decryptedName}>
|
||||
<Link href={file.decrypted} download={decryptedName}>
|
||||
<Button as="span">下载</Button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import type { RootState } from '~/store';
|
||||
|
||||
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
||||
import { decryptionQueue } from '~/decrypt-worker/client';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
||||
import type {
|
||||
DecryptCommandOptions,
|
||||
FetchMusicExNamePayload,
|
||||
ParseKuwoHeaderPayload,
|
||||
ParseKuwoHeaderResponse,
|
||||
} from '~/decrypt-worker/types';
|
||||
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector';
|
||||
import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector';
|
||||
|
||||
export enum ProcessState {
|
||||
QUEUED = 'QUEUED',
|
||||
@@ -43,8 +48,9 @@ export interface FileListingState {
|
||||
files: Record<string, DecryptedAudioFile>;
|
||||
displayMode: ListingMode;
|
||||
}
|
||||
|
||||
const initialState: FileListingState = {
|
||||
files: Object.create(null),
|
||||
files: {},
|
||||
displayMode: ListingMode.LIST,
|
||||
};
|
||||
|
||||
@@ -64,17 +70,21 @@ export const processFile = createAsyncThunk<
|
||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||
};
|
||||
|
||||
const fileHeader = await fetch(file.raw, {
|
||||
headers: {
|
||||
Range: 'bytes=0-1023',
|
||||
},
|
||||
})
|
||||
.then((r) => r.blob())
|
||||
.then((r) => r.arrayBuffer());
|
||||
const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([
|
||||
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
|
||||
blobURI: file.raw,
|
||||
}),
|
||||
workerClientBus.request<ParseKuwoHeaderResponse, ParseKuwoHeaderPayload>(
|
||||
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
|
||||
{ blobURI: file.raw },
|
||||
),
|
||||
]);
|
||||
|
||||
const options: DecryptCommandOptions = {
|
||||
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
|
||||
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
|
||||
fileName: file.fileName,
|
||||
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
||||
kwm2key: selectKWMv2Key(state, kuwoHdr),
|
||||
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
||||
};
|
||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||
});
|
||||
|
||||
@@ -30,10 +30,12 @@ import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||
import { selectIsSettingsNotSaved } from './settingsSelector';
|
||||
import { PanelQingTing } from './panels/PanelQingTing';
|
||||
|
||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||
{ name: '蜻蜓 FM', Tab: PanelQingTing },
|
||||
{
|
||||
name: '其它/待定',
|
||||
Tab: () => <Text>这里空空如也~</Text>,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { objectify } from 'radash';
|
||||
|
||||
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
||||
src: P,
|
||||
make: (k: keyof P, v: P[keyof P]) => null | S
|
||||
make: (k: keyof P, v: P[keyof P]) => null | S,
|
||||
): S[] {
|
||||
const result: S[] = [];
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
@@ -31,7 +31,7 @@ export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.nor
|
||||
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
|
||||
export const qmc2ProductionToStaging = (
|
||||
key: keyof ProductionQMCv2Keys,
|
||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
|
||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys],
|
||||
): StagingQMCv2Key => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
@@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = (
|
||||
|
||||
export interface StagingKWMv2Key {
|
||||
id: string;
|
||||
/**
|
||||
* Resource ID
|
||||
*/
|
||||
rid: string;
|
||||
/**
|
||||
* Quality String
|
||||
*/
|
||||
quality: string;
|
||||
ekey: string;
|
||||
}
|
||||
@@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
|
||||
|
||||
return { rid, quality };
|
||||
};
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`;
|
||||
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||
export const kwm2ProductionToStaging = (
|
||||
key: keyof ProductionKWMv2Keys,
|
||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
|
||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys],
|
||||
): null | StagingKWMv2Key => {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const parsed = parseKwm2ProductionKey(key);
|
||||
if (!parsed) return null;
|
||||
const { quality, rid } = parsed;
|
||||
|
||||
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value };
|
||||
return { id: nanoid(), rid, quality, ekey: value };
|
||||
};
|
||||
|
||||
33
src/features/settings/panels/KWMv2/InstructionsIOS.tsx
Normal file
33
src/features/settings/panels/KWMv2/InstructionsIOS.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react';
|
||||
|
||||
const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv';
|
||||
|
||||
export function InstructionsIOS() {
|
||||
return (
|
||||
<>
|
||||
<Text>你需要越狱来访问 iOS 应用的私有数据。</Text>
|
||||
<Text>
|
||||
⚠️ 请注意,越狱通常意味着你的设备
|
||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||
</Text>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
访问设备的这个目录:
|
||||
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code>
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提取密钥数据库文件 <Code>kw_ekey</Code> 至浏览器可访问的目录,如下载目录。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
提交刚刚提取的 <Code>kw_ekey</Code> 密钥数据库。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
import { InstructionsPC } from './InstructionsPC';
|
||||
import { InstructionsIOS } from './InstructionsIOS';
|
||||
|
||||
export function KWMv2AllInstructions() {
|
||||
return (
|
||||
<>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
<Tab>iOS</Tab>
|
||||
<Tab>Windows</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
@@ -16,6 +18,9 @@ export function KWMv2AllInstructions() {
|
||||
file="cn.kuwo.player.mmkv.defaultconfig"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsIOS />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsPC />
|
||||
</TabPanel>
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
import { MMKVParser } from '~/util/MMKVParser';
|
||||
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
|
||||
|
||||
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
|
||||
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
||||
@@ -41,9 +41,11 @@ export function PanelKWMv2Key() {
|
||||
const handleSecretImport = async (file: File) => {
|
||||
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
|
||||
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer));
|
||||
keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||
} else if (/kw_ekey/.test(file.name)) {
|
||||
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||
}
|
||||
|
||||
if (keys?.length === 0) {
|
||||
toast({
|
||||
title: '未导入密钥',
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Select,
|
||||
Text,
|
||||
Tooltip,
|
||||
useToast,
|
||||
@@ -28,15 +29,17 @@ import { InfoOutlineIcon } from '@chakra-ui/icons';
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
import { StagingQMCv2Key } from '../keyFormats';
|
||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
||||
import { MMKVParser } from '~/util/MMKVParser';
|
||||
import { parseAndroidQmEKey } from '~/util/mmkv/qm';
|
||||
import { getFileName } from '~/util/pathHelper';
|
||||
import { QMCv2AllInstructions } from './QMCv2/QMCv2AllInstructions';
|
||||
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
|
||||
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
|
||||
|
||||
export function PanelQMCv2Key() {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [secretType, setSecretType] = useState<'qm' | 'douban'>('qm');
|
||||
|
||||
const addKey = () => dispatch(qmc2AddKey());
|
||||
const clearAll = () => dispatch(qmc2ClearKeys());
|
||||
@@ -51,16 +54,16 @@ export function PanelQMCv2Key() {
|
||||
|
||||
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
||||
|
||||
if (/[_.]db$/i.test(file.name)) {
|
||||
if (/(player_process[_.]db|music_audio_play)(\.db)?$/i.test(file.name)) {
|
||||
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
||||
qmc2Keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
|
||||
if (!qmc2Keys) {
|
||||
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
||||
alert(`不是支持的 SQLite 数据库文件。`);
|
||||
return;
|
||||
}
|
||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
|
||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const map = MMKVParser.toStringMap(new DataView(fileBuffer));
|
||||
const map = parseAndroidQmEKey(new DataView(fileBuffer));
|
||||
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||
}
|
||||
|
||||
@@ -96,8 +99,8 @@ export function PanelQMCv2Key() {
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
QQ 音乐目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS
|
||||
客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
|
||||
QQ 音乐、豆瓣 FM 目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS 客户端,以及在使用「豆瓣
|
||||
FM」安卓客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
|
||||
</Text>
|
||||
|
||||
<HStack pb={2} pt={2}>
|
||||
@@ -155,16 +158,28 @@ export function PanelQMCv2Key() {
|
||||
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
||||
))}
|
||||
</List>
|
||||
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
{qmc2Keys.length === 0 && <Text>还没有密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportSecretModal
|
||||
clientName="QQ 音乐"
|
||||
clientName={
|
||||
<Select
|
||||
value={secretType}
|
||||
onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')}
|
||||
variant="flushed"
|
||||
display="inline"
|
||||
css={{ paddingLeft: '0.75rem', width: 'auto' }}
|
||||
>
|
||||
<option value="qm">QQ 音乐</option>
|
||||
<option value="douban">豆瓣 FM</option>
|
||||
</Select>
|
||||
}
|
||||
show={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={handleSecretImport}
|
||||
>
|
||||
<QMCv2AllInstructions />
|
||||
{secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
|
||||
{secretType === 'douban' && <QMCv2DoubanAllInstructions />}
|
||||
</ImportSecretModal>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
127
src/features/settings/panels/PanelQingTing.tsx
Normal file
127
src/features/settings/panels/PanelQingTing.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Heading,
|
||||
Input,
|
||||
ListItem,
|
||||
Text,
|
||||
UnorderedList,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||
import { ExtLink } from '~/components/ExtLink';
|
||||
import { ChangeEvent, ClipboardEvent } from 'react';
|
||||
import { VQuote } from '~/components/HelpText/VQuote';
|
||||
import { selectStagingQtfmAndroidKey } from '../settingsSelector';
|
||||
import { qtfmAndroidUpdateKey } from '../settingsSlice';
|
||||
import { workerClientBus } from '~/decrypt-worker/client.ts';
|
||||
import { GetQingTingFMDeviceKeyPayload } from '~/decrypt-worker/types.ts';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME } from '~/decrypt-worker/constants.ts';
|
||||
|
||||
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
|
||||
|
||||
export function PanelQingTing() {
|
||||
const dispatch = useAppDispatch();
|
||||
const secretKey = useAppSelector(selectStagingQtfmAndroidKey);
|
||||
const setSecretKey = (secretKey: string) => {
|
||||
dispatch(qtfmAndroidUpdateKey({ deviceKey: secretKey }));
|
||||
};
|
||||
|
||||
const handleDataPaste = (e: ClipboardEvent<HTMLInputElement>) => {
|
||||
const plainText = e.clipboardData.getData('text/plain');
|
||||
const matchDeviceSecret = plainText.match(/^DEVICE_SECRET: ([0-9a-fA-F]+)/m);
|
||||
if (matchDeviceSecret) {
|
||||
e.preventDefault();
|
||||
setSecretKey(matchDeviceSecret[1]);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataMap = Object.create(null);
|
||||
for (const [, key, value] of plainText.matchAll(/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim)) {
|
||||
dataMap[key.toLowerCase()] = value;
|
||||
}
|
||||
const { product, device, manufacturer, brand, board, model } = dataMap;
|
||||
|
||||
if (product && device && manufacturer && brand && board && model) {
|
||||
e.preventDefault();
|
||||
workerClientBus
|
||||
.request<string, GetQingTingFMDeviceKeyPayload>(
|
||||
DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY,
|
||||
dataMap,
|
||||
)
|
||||
.then(setSecretKey)
|
||||
.catch((err) => {
|
||||
alert(`生成设备密钥时发生错误: ${err}`);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataInput = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSecretKey(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH={0} flexDir="column" flex={1}>
|
||||
<Heading as="h2" size="lg">
|
||||
<VQuote>蜻蜓 FM</VQuote>
|
||||
设备密钥
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
<VQuote>蜻蜓 FM</VQuote>的安卓版本需要获取设备密钥,并以此来生成解密密钥。
|
||||
</Text>
|
||||
<Box mt={3} mb={3}>
|
||||
<FormControl>
|
||||
<FormLabel>设备密钥</FormLabel>
|
||||
<Input type="text" onPaste={handleDataPaste} value={secretKey} onChange={handleDataInput} />
|
||||
<FormHelperText>
|
||||
{'粘贴含有设备密钥的信息的内容时将自动提取密钥(如通过 '}
|
||||
<ExtLink href={QTFM_DEVICE_ID_URL}>
|
||||
<Code>qtfm-device-id</Code>
|
||||
</ExtLink>
|
||||
{' 获取的设备信息)。'}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Heading as="h3" size="md" pt={3} pb={2}>
|
||||
注意事项
|
||||
</Heading>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
下载的文件位于
|
||||
<Code>[内部储存]/Android/data/fm.qingting.qtradio/files/Music/</Code>
|
||||
</Text>
|
||||
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
你可能需要使用有
|
||||
<ruby>
|
||||
特权
|
||||
<rp> (</rp>
|
||||
<rt>root</rt>
|
||||
<rp>)</rp>
|
||||
</ruby>
|
||||
的文件浏览器访问。
|
||||
</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
音频文件文件名为「<Code>.p~!</Code>」前缀。
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>因为解密密钥与文件名相关,因此解密前请不要更改文件名。</Text>
|
||||
</ListItem>
|
||||
</UnorderedList>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,8 @@ import { Text } from '@chakra-ui/react';
|
||||
export function InstructionsPC() {
|
||||
return (
|
||||
<>
|
||||
<Text>使用 Windows 客户端下载的文件不需要导入密钥。</Text>
|
||||
<Text>使用 Windows 19.43 或更低版本下载的歌曲文件无需密钥。</Text>
|
||||
<Text>使用 Windows 19.51 或更高版本下载的歌曲文件需要导入密钥,但方法尚未公开。</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
|
||||
export function QMCv2DoubanAllInstructions() {
|
||||
return (
|
||||
<>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
<TabPanel>
|
||||
<AndroidADBPullInstruction dir="/data/data/com.douban.radio/databases" file="music_audio_play" />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { InstructionsIOS } from './InstructionsIOS';
|
||||
import { InstructionsMac } from './InstructionsMac';
|
||||
import { InstructionsPC } from './InstructionsPC';
|
||||
|
||||
export function QMCv2AllInstructions() {
|
||||
export function QMCv2QQMusicAllInstructions() {
|
||||
return (
|
||||
<>
|
||||
<TabList>
|
||||
@@ -0,0 +1 @@
|
||||
// TODO: Popup dialog for QingTing instructions
|
||||
@@ -1,39 +1,44 @@
|
||||
import { debounce } from 'radash';
|
||||
import { produce } from 'immer';
|
||||
|
||||
import type { AppStore } from '~/store';
|
||||
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
||||
import { enumObject } from '~/util/objects';
|
||||
import { getLogger } from '~/util/logUtils';
|
||||
import { parseKwm2ProductionKey } from './keyFormats';
|
||||
import { deepClone } from '~/util/deepClone';
|
||||
|
||||
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
||||
|
||||
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
||||
return produce(settingsSlice.getInitialState().production, (draft) => {
|
||||
if (settings?.qmc2) {
|
||||
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string') {
|
||||
draft.qmc2.keys[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof allowFuzzyNameSearch === 'boolean') {
|
||||
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
|
||||
const draft = deepClone(settingsSlice.getInitialState().production);
|
||||
if (settings?.qmc2) {
|
||||
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string') {
|
||||
draft.qmc2.keys[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.kwm2) {
|
||||
const { keys } = settings.kwm2;
|
||||
if (typeof allowFuzzyNameSearch === 'boolean') {
|
||||
draft.qmc2.allowFuzzyNameSearch = allowFuzzyNameSearch;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
|
||||
draft.kwm2.keys[k] = v;
|
||||
}
|
||||
if (settings?.kwm2) {
|
||||
const { keys } = settings.kwm2;
|
||||
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string' && parseKwm2ProductionKey(k)) {
|
||||
draft.kwm2.keys[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof settings?.qtfm?.android === 'string') {
|
||||
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) {
|
||||
@@ -58,6 +63,6 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
|
||||
localStorage.setItem(storageKey, JSON.stringify(currentSettings));
|
||||
getLogger().debug('settings saved');
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { parseKuwoHeader } from '~/crypto/parseKuwo';
|
||||
import type { RootState } from '~/store';
|
||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||
import { hasOwn } from '~/util/objects';
|
||||
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||
import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
|
||||
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
||||
|
||||
@@ -31,14 +31,16 @@ export const selectQMCv2KeyByFileName = (state: RootState, name: string): string
|
||||
return ekey;
|
||||
};
|
||||
|
||||
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => {
|
||||
const hdr = parseKuwoHeader(headerView);
|
||||
export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse): string | undefined => {
|
||||
if (!hdr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quality = String(hdr.qualityId);
|
||||
const rid = String(hdr.resourceId);
|
||||
|
||||
const keys = selectFinalKWMv2Keys(state);
|
||||
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality: hdr.quality, rid: hdr.rid });
|
||||
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality, rid });
|
||||
|
||||
let ekey: string | undefined;
|
||||
if (hasOwn(keys, lookupKey)) {
|
||||
@@ -47,3 +49,6 @@ export const selectKWMv2Key = (state: RootState, headerView: DataView): string |
|
||||
|
||||
return ekey;
|
||||
};
|
||||
|
||||
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
||||
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
||||
|
||||
@@ -24,6 +24,9 @@ export interface StagingSettings {
|
||||
kwm2: {
|
||||
keys: StagingKWMv2Key[];
|
||||
};
|
||||
qtfm: {
|
||||
android: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductionSettings {
|
||||
@@ -34,6 +37,9 @@ export interface ProductionSettings {
|
||||
kwm2: {
|
||||
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
||||
};
|
||||
qtfm: {
|
||||
android: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
@@ -46,10 +52,12 @@ const initialState: SettingsState = {
|
||||
staging: {
|
||||
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
||||
kwm2: { keys: [] },
|
||||
qtfm: { android: '' },
|
||||
},
|
||||
production: {
|
||||
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
||||
kwm2: { keys: {} },
|
||||
qtfm: { android: '' },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -61,6 +69,7 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||
kwm2: {
|
||||
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
||||
},
|
||||
qtfm: staging.qtfm,
|
||||
});
|
||||
|
||||
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
||||
@@ -71,6 +80,7 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
|
||||
kwm2: {
|
||||
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
||||
},
|
||||
qtfm: production.qtfm,
|
||||
});
|
||||
|
||||
export const settingsSlice = createSlice({
|
||||
@@ -101,7 +111,7 @@ export const settingsSlice = createSlice({
|
||||
},
|
||||
qmc2UpdateKey(
|
||||
state,
|
||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }>
|
||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }>,
|
||||
) {
|
||||
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
|
||||
if (keyItem) {
|
||||
@@ -134,7 +144,7 @@ export const settingsSlice = createSlice({
|
||||
},
|
||||
kwm2UpdateKey(
|
||||
state,
|
||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }>
|
||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }>,
|
||||
) {
|
||||
const keyItem = state.staging.kwm2.keys.find((item) => item.id === id);
|
||||
if (keyItem) {
|
||||
@@ -142,6 +152,10 @@ export const settingsSlice = createSlice({
|
||||
state.dirty = true;
|
||||
}
|
||||
},
|
||||
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
|
||||
state.staging.qtfm.android = deviceKey;
|
||||
state.dirty = true;
|
||||
},
|
||||
kwm2ClearKeys(state) {
|
||||
state.staging.kwm2.keys = [];
|
||||
state.dirty = true;
|
||||
@@ -183,6 +197,8 @@ export const {
|
||||
kwm2ClearKeys,
|
||||
kwm2ImportKeys,
|
||||
|
||||
qtfmAndroidUpdateKey,
|
||||
|
||||
commitStagingChange,
|
||||
discardStagingChanges,
|
||||
} = settingsSlice.actions;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import './pwa';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
@@ -13,5 +14,5 @@ SyntaxHighlighter.registerLanguage('bash', hljsSyntaxBash);
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<AppRoot />
|
||||
</React.StrictMode>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
10
src/pwa.ts
Normal file
10
src/pwa.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
const updateSW = registerSW({
|
||||
onNeedRefresh() {
|
||||
if (confirm('应用程序已更新,是否刷新?')) {
|
||||
updateSW();
|
||||
}
|
||||
},
|
||||
onOfflineReady() {},
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PreloadedState, combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import { combineReducers, configureStore } from '@reduxjs/toolkit';
|
||||
import fileListingReducer from './features/file-listing/fileListingSlice';
|
||||
import settingsReducer from './features/settings/settingsSlice';
|
||||
|
||||
@@ -7,12 +7,13 @@ const rootReducer = combineReducers({
|
||||
settings: settingsReducer,
|
||||
});
|
||||
|
||||
export const setupStore = (preloadedState?: PreloadedState<RootState>) =>
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
|
||||
export const setupStore = (preloadedState?: Partial<RootState>) =>
|
||||
configureStore({
|
||||
reducer: rootReducer,
|
||||
preloadedState,
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof rootReducer>;
|
||||
export type AppStore = ReturnType<typeof setupStore>;
|
||||
export type AppDispatch = AppStore['dispatch'];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { PreloadedState } from '@reduxjs/toolkit';
|
||||
import { RenderOptions, render } from '@testing-library/react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -10,13 +9,13 @@ import { AppStore, RootState, setupStore } from '~/store';
|
||||
export * from '@testing-library/react';
|
||||
|
||||
export interface ExtendedRenderOptions extends RenderOptions {
|
||||
preloadedState?: PreloadedState<RootState>;
|
||||
preloadedState?: Partial<RootState>;
|
||||
store?: AppStore;
|
||||
}
|
||||
|
||||
export function renderWithProviders(
|
||||
ui: React.ReactElement,
|
||||
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {}
|
||||
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {},
|
||||
) {
|
||||
function Wrapper({ children }: PropsWithChildren<unknown>): JSX.Element {
|
||||
return <Provider store={store}>{children}</Provider>;
|
||||
|
||||
16
src/theme.ts
16
src/theme.ts
@@ -9,6 +9,12 @@ export const theme = extendTheme({
|
||||
'Segoe UI,Helvetica,Arial,sans-serif',
|
||||
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
|
||||
].join(','),
|
||||
mono: [
|
||||
'SFMono-Regular,Menlo,Monaco',
|
||||
'"Sarasa Mono CJK SC"',
|
||||
'Consolas,"Liberation Mono","Courier New",monospace',
|
||||
'"Microsoft YaHei UI"',
|
||||
].join(','),
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
@@ -25,6 +31,16 @@ export const theme = extendTheme({
|
||||
color: 'blue.600',
|
||||
},
|
||||
},
|
||||
Text: {
|
||||
baseStyle: {
|
||||
mt: 1,
|
||||
},
|
||||
},
|
||||
Header: {
|
||||
baseStyle: {
|
||||
mt: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
styles: {
|
||||
global: {
|
||||
|
||||
@@ -23,16 +23,21 @@ export class DatabaseKeyExtractor {
|
||||
return tables.includes(name);
|
||||
}
|
||||
|
||||
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
|
||||
extractQmcV2KeysFromSqliteDb(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
|
||||
let db: SQLDatabase | null = null;
|
||||
|
||||
try {
|
||||
db = new this.SQL.Database(new Uint8Array(buffer));
|
||||
if (!this.hasTable(db, 'audio_file_ekey_table')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = db.exec('select file_path, ekey from audio_file_ekey_table');
|
||||
let sql: undefined | string;
|
||||
if (this.hasTable(db, 'audio_file_ekey_table')) {
|
||||
sql = 'select file_path, ekey from audio_file_ekey_table';
|
||||
} else if (this.hasTable(db, 'EKeyFileInfo')) {
|
||||
sql = 'select filePath, eKey from EKeyFileInfo';
|
||||
}
|
||||
if (!sql) return null;
|
||||
|
||||
const result = db.exec(sql);
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
|
||||
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
|
||||
import { formatHex } from './formatHex';
|
||||
import { formatHex } from './hex.ts';
|
||||
|
||||
export class MMKVParser {
|
||||
private offset = 4;
|
||||
@@ -69,7 +68,11 @@ export class MMKVParser {
|
||||
return bytesToUTF8String(data).normalize();
|
||||
}
|
||||
|
||||
public readOptionalString() {
|
||||
public readKey() {
|
||||
return this.readString();
|
||||
}
|
||||
|
||||
public readStringValue(): string | null {
|
||||
// Container [
|
||||
// len: int,
|
||||
// data: variant
|
||||
@@ -96,37 +99,4 @@ export class MMKVParser {
|
||||
const containerLen = this.readInt();
|
||||
this.offset += containerLen;
|
||||
}
|
||||
|
||||
public static toStringMap(view: DataView): Map<string, string> {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result = new Map<string, string>();
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readString();
|
||||
const value = mmkv.readOptionalString();
|
||||
if (value) {
|
||||
result.set(key, value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static parseKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
const key = mmkv.readString();
|
||||
const idMatch = key.match(/^sec_ekey#(\d+)-(.+)/);
|
||||
if (!idMatch) {
|
||||
mmkv.skipContainer();
|
||||
continue;
|
||||
}
|
||||
|
||||
const [_, rid, quality] = idMatch;
|
||||
const ekey = mmkv.readOptionalString();
|
||||
if (ekey) {
|
||||
result.push({ rid, quality, ekey });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,15 +18,18 @@ test('should be able to forward request to worker client bus', async () => {
|
||||
vi.spyOn(bus, 'request').mockImplementation(
|
||||
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
|
||||
return { actionName, payload };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const queue = new DecryptionQueue(bus, 1);
|
||||
await expect(queue.add({ id: 'file://1', blobURI: 'blob://mock-file' })).resolves.toEqual({
|
||||
await expect(
|
||||
queue.add({ id: 'file://1', blobURI: 'blob://mock-file', options: { fileName: 'test.bin' } }),
|
||||
).resolves.toEqual({
|
||||
actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT,
|
||||
payload: {
|
||||
blobURI: 'blob://mock-file',
|
||||
id: 'file://1',
|
||||
options: { fileName: 'test.bin' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,46 +1,28 @@
|
||||
import { MMKVParser } from '../MMKVParser';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const makeViewFromBuffer = (buff: Buffer) =>
|
||||
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
|
||||
|
||||
test('parse qm mmkv file', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
|
||||
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
|
||||
"key": "value",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('parse qm mmkv file with optional str', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm_optional.mmkv'));
|
||||
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
|
||||
{
|
||||
"key": "value",
|
||||
"key2": "value2",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test('parse kuwo mmkv file', () => {
|
||||
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo.mmkv'));
|
||||
expect(MMKVParser.parseKuwoEKey(view)).toMatchInlineSnapshot(`
|
||||
[
|
||||
{
|
||||
"ekey": "xyz123",
|
||||
"quality": "20201kmflac",
|
||||
"rid": "1234567",
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('throw error on broken file', () => {
|
||||
const view = makeViewFromBuffer(
|
||||
Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]),
|
||||
);
|
||||
|
||||
expect(() => Object.fromEntries(MMKVParser.toStringMap(view).entries())).toThrow(/offset mismatch/i);
|
||||
expect(() => {
|
||||
const parser = new MMKVParser(view);
|
||||
parser.readKey();
|
||||
parser.readStringValue();
|
||||
}).toThrow(/offset mismatch/i);
|
||||
});
|
||||
|
||||
test('able to handle empty value', () => {
|
||||
const view = makeViewFromBuffer(
|
||||
Buffer.from([0x0b, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x00, 0x01, 0x31, 0x02, 0x01, 0x32, 0xff]),
|
||||
);
|
||||
|
||||
const parser = new MMKVParser(view);
|
||||
expect(parser.readKey()).toEqual('key');
|
||||
expect(parser.readStringValue()).toEqual(null);
|
||||
expect(parser.readKey()).toEqual('1');
|
||||
expect(parser.readStringValue()).toEqual('2');
|
||||
});
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user