mirror of
https://git.um-react.app/um/um-react.git
synced 2025-11-28 19:43:02 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb7a634b1 | ||
|
|
ce969af57f | ||
|
|
ec4bd16b03 | ||
|
|
531930a6ec | ||
|
|
3862f2d38e | ||
|
|
ddc073fbcc | ||
|
|
82dbfc2d1f | ||
|
|
87d2d71193 | ||
|
|
759252cec5 | ||
|
|
afc65fd5d0 | ||
|
|
9f587212bc | ||
|
|
9ede00037e | ||
|
|
0951963f46 | ||
|
|
c57bc9cfbb | ||
|
|
b16e3bf3ea | ||
|
|
e9a95d1bd6 | ||
|
|
00813957d6 | ||
|
|
b26e62e8d9 | ||
|
|
9fed1ee610 | ||
|
|
5e890bca77 | ||
|
|
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 |
33
.drone.yml
33
.drone.yml
@@ -1,33 +0,0 @@
|
|||||||
---
|
|
||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: default
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test & build
|
|
||||||
image: node:18.16.1-bookworm
|
|
||||||
commands:
|
|
||||||
# - git config --global --add safe.directory "/drone/src"
|
|
||||||
- corepack enable
|
|
||||||
- corepack prepare pnpm@latest --activate
|
|
||||||
- pnpm i --frozen-lockfile
|
|
||||||
- pnpm build
|
|
||||||
environment:
|
|
||||||
# 让 npm 使用淘宝源
|
|
||||||
npm_config_registry: https://registry.npmmirror.com
|
|
||||||
|
|
||||||
- name: publish
|
|
||||||
image: node:18.16.1-bookworm
|
|
||||||
environment:
|
|
||||||
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
|
||||||
GITEA_API_KEY:
|
|
||||||
from_secret: GITEA_API_KEY
|
|
||||||
NETLIFY_SITE_ID:
|
|
||||||
from_secret: NETLIFY_SITE_ID
|
|
||||||
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/.
|
|
||||||
- ./scripts/publish.sh
|
|
||||||
- ./scripts/deploy.sh
|
|
||||||
@@ -11,5 +11,5 @@ charset = utf-8
|
|||||||
trim_trailing_whitespace = true
|
trim_trailing_whitespace = true
|
||||||
insert_final_newline = true
|
insert_final_newline = true
|
||||||
|
|
||||||
[*.{{c,m,}js{x,on,},ts{x,}}]
|
[*.{{c,m,}js{x,on,},ts{x,},y{,a}ml}]
|
||||||
indent_size = 2
|
indent_size = 2
|
||||||
|
|||||||
2
.env
2
.env
@@ -1,4 +1,4 @@
|
|||||||
# Example environment file for vite to use.
|
# Example environment file for vite to use.
|
||||||
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
|
||||||
|
|
||||||
ENABLE_PERF_LOG=0
|
VITE_ENABLE_PERF_LOG=0
|
||||||
|
|||||||
34
.gitea/workflows/build.yaml
Normal file
34
.gitea/workflows/build.yaml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
name: Build and Deploy
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
npm_config_registry: https://registry.npmmirror.com
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4.0.0
|
||||||
|
with:
|
||||||
|
standalone: true
|
||||||
|
run_install: |
|
||||||
|
- args: [--frozen-lockfile, --strict-peer-dependencies]
|
||||||
|
- name: Build
|
||||||
|
run: pnpm build
|
||||||
|
- name: Prepare for deployment
|
||||||
|
run: |
|
||||||
|
python3 -m zipfile -c um-react.zip dist/.
|
||||||
|
cp um-react.zip dist/"release-${GITHUB_SHA}.zip"
|
||||||
|
python3 -m zipfile -c um-react-site.zip dist/.
|
||||||
|
- name: Publish Artifact
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: site
|
||||||
|
path: dist/
|
||||||
|
- name: Deploy
|
||||||
|
env:
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
NETLIFY_API_KEY: ${{ secrets.NETLIFY_API_KEY }}
|
||||||
|
run: ./scripts/deploy.sh
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -27,3 +27,8 @@ dist-ssr
|
|||||||
# Files created when running "drone exec" locally
|
# Files created when running "drone exec" locally
|
||||||
/.pnpm-store/
|
/.pnpm-store/
|
||||||
/*.zip
|
/*.zip
|
||||||
|
|
||||||
|
/um-react-wry-*
|
||||||
|
/um-react*.exe
|
||||||
|
|
||||||
|
/win64/
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm exec lint-staged
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env sh
|
|
||||||
. "$(dirname -- "$0")/_/husky.sh"
|
|
||||||
|
|
||||||
pnpm test
|
|
||||||
4
.npmrc
4
.npmrc
@@ -1,3 +1,3 @@
|
|||||||
use-node-version=18.16.0
|
use-node-version=22.12.0
|
||||||
node-version=18.16.0
|
|
||||||
engine-strict=true
|
engine-strict=true
|
||||||
|
@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>
|
||||||
76
README.MD
76
README.MD
@@ -3,32 +3,58 @@
|
|||||||
[](https://ci.unlock-music.dev/um/um-react)
|
[](https://ci.unlock-music.dev/um/um-react)
|
||||||
|
|
||||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||||
|
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
||||||
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
||||||
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
||||||
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
||||||
- CI 自动构建已经部署,可以在 [Packages][um-react-packages] 下载。
|
- CI 自动构建已经部署,可以在 [Actions][um-react-actions] 寻找对应的<ruby>构建产物<rp>(</rp><rt>Artifact</rt><rp>)</rp> </ruby>下载。
|
||||||
- [常见问题参考](./docs/faq_zh-hans.md)
|
- [常见问题参考](./docs/faq_zh-hans.md)
|
||||||
|
|
||||||
|
> **WARNING**
|
||||||
|
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
||||||
|
|
||||||
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
|
[授权协议]: 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/cli]: https://git.unlock-music.dev/um/cli
|
||||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||||
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
|
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions
|
||||||
|
|
||||||
|
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||||
|
|
||||||
## 支持的格式
|
## 支持的格式
|
||||||
|
|
||||||
- [x] QQ 音乐 QMCv1 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
|
- [x] QQ 音乐 QMCv1 (`.qmc3` / `.qmcflac` 等)
|
||||||
- [x] QQ 音乐 QMCv2 PC 端 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
|
- [x] QQ 音乐 QMCv2
|
||||||
- [x] 网易云音乐 (.ncm)
|
- PC 客户端 (`.mflac` / `.mgg` 等) [^qm-key-pc]
|
||||||
- [x] 虾米音乐 (.xm)
|
- 安卓客户端 (`.mflac0` / `.mgg1` / `.mggl` 等) [^qm-key-android]
|
||||||
- [x] 酷我音乐 (.kwm)
|
- iOS 客户端 (`.mgalaxy` 等) [^qm-key-ios]
|
||||||
- [x] 酷狗音乐 (.kgm/.vpr)
|
- Mac 客户端 (`.mflach` 等) [^qm-key-mac]
|
||||||
- [x] 喜马拉雅 Android 端 (.x2m/.x3m)
|
- [x] 网易云音乐 (`.ncm`)
|
||||||
- [x] 咪咕音乐格式 (.mg3d)
|
- [x] 虾米音乐 (`.xm`)
|
||||||
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~
|
- [x] 酷我音乐 (`.kwm`)
|
||||||
|
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
|
||||||
|
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
|
||||||
|
- [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/) 等。
|
||||||
|
|
||||||
|
遇到解密出错的情况,请一并携带错误信息(诊断信息)并简单描述错误的重现过程。
|
||||||
|
|
||||||
|
待实现的算法支持可[追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)。
|
||||||
|
|
||||||
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
||||||
|
|
||||||
@@ -36,11 +62,11 @@
|
|||||||
|
|
||||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||||
|
|
||||||
### 面向 libparakeet SDK 开发
|
### 解密库开发
|
||||||
|
|
||||||
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
|
||||||
|
|
||||||
请参考文档「[面向 `libparakeet-js` 开发](./docs/develop-with-libparakeet.zh.md)」。
|
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。
|
||||||
|
|
||||||
### 架构
|
### 架构
|
||||||
|
|
||||||
@@ -59,12 +85,16 @@
|
|||||||
|
|
||||||
满足上述条件后发起 Pull Request,仓库管理员审阅后将合并到主分支。
|
满足上述条件后发起 Pull Request,仓库管理员审阅后将合并到主分支。
|
||||||
|
|
||||||
## TODO
|
## 相关项目
|
||||||
|
|
||||||
- 待定
|
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
||||||
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
|
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||||
- [ ] #7 简易元数据编辑器
|
- [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)
|
||||||
- [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。
|
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||||
- [x] #2 解密内容探测 (解密过程)
|
需要[安装 Edge WebView2 运行时][webview2_redist],Win10+ 操作系统自带)
|
||||||
- [x] #6 文件拖放 (利用 `react-dropzone`?)
|
- [本地下载](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],请带上项目名称和链接。
|
||||||
|
|||||||
@@ -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、检查您的平台。
|
#### 2、检查您的平台。
|
||||||
|
|
||||||
日前,<mark>仅 Windows 客户端</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
|
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||||
|
|
||||||
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
|
> iOS 用户提取歌曲困难,建议换用电脑操作;Android 用户提取密钥需要 root,也建议用电脑操作。
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@
|
|||||||
|
|
||||||
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密,手机平台的其他音质暂时不需要提取密钥,PC 平台暂未推出使用新版加密的音质。
|
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密,手机平台的其他音质暂时不需要提取密钥,PC 平台暂未推出使用新版加密的音质。
|
||||||
|
|
||||||
|
※ 已知部分第三方修改版会破坏密钥写出功能,导致无法导入密钥。请使用官方版本。
|
||||||
|
|
||||||
> Android 用户提取密钥需要 root,或者注入文件提供器。
|
> Android 用户提取密钥需要 root,或者注入文件提供器。
|
||||||
|
|
||||||
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
|
提取密钥教程请访问[新版解锁网站](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]即可。
|
如果需要预览构建版本,运行 `pnpm preview` 然后打开[项目预览页面][vite-preview-url]即可。
|
||||||
|
|
||||||
[vite-preview-url]: http://localhost:4173/
|
[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]` 即可。
|
||||||
|
|||||||
115
package.json
115
package.json
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "um-react",
|
"name": "um-react",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.1",
|
"version": "0.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "vite",
|
"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",
|
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
"format": "prettier -w .",
|
"format": "prettier -w .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -13,64 +14,71 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
"preview:coverage": "vite preview --outDir coverage --port 5175",
|
||||||
"prepare": "husky install"
|
"prepare": "simple-git-hooks"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@chakra-ui/anatomy": "^2.2.1",
|
"@chakra-ui/anatomy": "^2.3.4",
|
||||||
"@chakra-ui/icons": "^2.1.1",
|
"@chakra-ui/icons": "^2.2.4",
|
||||||
"@chakra-ui/react": "^2.8.1",
|
"@chakra-ui/react": "^2.10.4",
|
||||||
"@emotion/react": "^11.11.1",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.11.0",
|
"@emotion/styled": "^11.14.0",
|
||||||
"@jixun/libparakeet": "0.3.0",
|
"@reduxjs/toolkit": "^2.5.0",
|
||||||
"@reduxjs/toolkit": "^1.9.7",
|
"@unlock-music/crypto": "0.1.2",
|
||||||
"framer-motion": "^10.16.4",
|
"framer-motion": "^11.14.4",
|
||||||
"immer": "^10.0.3",
|
"nanoid": "^5.0.9",
|
||||||
"nanoid": "^5.0.1",
|
"radash": "^12.1.0",
|
||||||
"radash": "^11.0.0",
|
"react": "^18.3.1",
|
||||||
"react": "^18.2.0",
|
"react-dom": "^18.3.1",
|
||||||
"react-dom": "^18.2.0",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-dropzone": "^14.2.3",
|
"react-icons": "^5.4.0",
|
||||||
"react-icons": "^4.11.0",
|
|
||||||
"react-promise-suspense": "^0.3.4",
|
"react-promise-suspense": "^0.3.4",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^9.2.0",
|
||||||
"react-syntax-highlighter": "^15.5.0",
|
"react-syntax-highlighter": "^15.6.1",
|
||||||
"sass": "^1.69.2",
|
"sass": "^1.83.0",
|
||||||
"sql.js": "^1.8.0"
|
"sql.js": "^1.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-replace": "^5.0.3",
|
"@rollup/plugin-replace": "^6.0.1",
|
||||||
"@testing-library/jest-dom": "^6.1.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^14.0.0",
|
"@testing-library/react": "^16.1.0",
|
||||||
"@testing-library/user-event": "^14.5.1",
|
"@testing-library/user-event": "^14.5.2",
|
||||||
"@types/node": "^20.8.4",
|
"@types/node": "^22.10.2",
|
||||||
"@types/react": "^18.2.28",
|
"@types/react": "^18.3.16",
|
||||||
"@types/react-dom": "^18.2.13",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/react-syntax-highlighter": "^15.5.8",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@types/sql.js": "^1.4.5",
|
"@types/sql.js": "^1.4.9",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.7.5",
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
"@typescript-eslint/parser": "^6.7.5",
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
"@vitejs/plugin-react": "^4.1.0",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"@vitest/coverage-v8": "^0.34.6",
|
"@vitest/coverage-v8": "^2.1.8",
|
||||||
"@vitest/ui": "^0.34.6",
|
"@vitest/ui": "^2.1.8",
|
||||||
"eslint": "^8.51.0",
|
"eslint": "^8.57.1",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.2",
|
||||||
"eslint-plugin-react-refresh": "^0.4.3",
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
"husky": "^8.0.3",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^25.0.1",
|
||||||
"lint-staged": "^14.0.1",
|
"lint-staged": "^15.2.11",
|
||||||
"prettier": "^3.0.3",
|
"prettier": "^3.4.2",
|
||||||
"typescript": "^5.2.2",
|
"rollup": "^4.28.1",
|
||||||
"vite": "^4.4.11",
|
"simple-git-hooks": "^2.11.1",
|
||||||
"vite-plugin-pwa": "^0.16.5",
|
"terser": "^5.37.0",
|
||||||
"vite-plugin-top-level-await": "^1.3.1",
|
"typescript": "^5.7.2",
|
||||||
"vite-plugin-wasm": "^3.2.2",
|
"vite": "^5.4.11",
|
||||||
"vitest": "^0.34.6"
|
"vite-plugin-pwa": "^0.20.5",
|
||||||
|
"vite-plugin-top-level-await": "^1.4.4",
|
||||||
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
|
"vitest": "^2.1.8",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "prettier --write --ignore-unknown",
|
"*": "prettier --write --ignore-unknown",
|
||||||
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
"*.{js,jsx,ts,tsx}": "eslint --fix --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
|
"simple-git-hooks": {
|
||||||
|
"pre-commit": "pnpm exec lint-staged",
|
||||||
|
"pre-push": "pnpm test"
|
||||||
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"printWidth": 120,
|
"printWidth": 120,
|
||||||
@@ -78,12 +86,13 @@
|
|||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
|
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
|
||||||
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
|
"sql.js": "patches/sql.js.patch"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
|
||||||
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
|
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
|
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
|
--- a/dist/sql-wasm.js
|
||||||
+++ b/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'){
|
else if (typeof exports === 'object'){
|
||||||
exports["Module"] = initSqlJs;
|
exports["Module"] = initSqlJs;
|
||||||
}
|
}
|
||||||
+
|
+
|
||||||
+var module;
|
+var module;
|
||||||
+export default initSqlJs;
|
+export default initSqlJs;
|
||||||
+
|
|
||||||
11525
pnpm-lock.yaml
generated
11525
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -96,15 +96,15 @@ deploy_netlify() {
|
|||||||
echo " * ${error_message}"
|
echo " * ${error_message}"
|
||||||
return 1
|
return 1
|
||||||
else
|
else
|
||||||
echo 'Deoployed to main url.'
|
echo 'Deployed to main url.'
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# For deployment, we care a bit less
|
# For deployment, we care a bit less
|
||||||
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
|
||||||
echo "Deploy to netlify..."
|
echo "Deploy to netlify (branch: ${BRANCH_NAME})..."
|
||||||
deploy_netlify um-react.zip
|
deploy_netlify um-react-site.zip
|
||||||
else
|
else
|
||||||
echo "skip netlify deployment."
|
echo "skip netlify deployment."
|
||||||
fi
|
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,
|
Box,
|
||||||
Code,
|
Code,
|
||||||
Heading,
|
Heading,
|
||||||
Link,
|
|
||||||
ListItem,
|
ListItem,
|
||||||
OrderedList,
|
OrderedList,
|
||||||
Text,
|
Text,
|
||||||
@@ -19,6 +18,7 @@ import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/githu
|
|||||||
|
|
||||||
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
|
||||||
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
|
||||||
|
import { ExtLink } from '../ExtLink';
|
||||||
|
|
||||||
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
|
||||||
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>
|
<Text>
|
||||||
你需要 <code>root</code> 访问权限来访问安卓应用的私有数据。
|
你需要
|
||||||
|
<ruby>
|
||||||
|
超级管理员
|
||||||
|
<rp> (</rp>
|
||||||
|
<rt>
|
||||||
|
<code>root</code>
|
||||||
|
</rt>
|
||||||
|
<rp>)</rp>
|
||||||
|
</ruby>
|
||||||
|
访问权限来访问安卓应用的私有数据。
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
⚠️ 请注意,获取 <code>root</code> 通常意味着你的安卓设备
|
⚠️ 请注意,获取管理员权限通常意味着你的安卓设备
|
||||||
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
<chakra.span color="red.400">将失去保修资格</chakra.span>。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -92,13 +101,13 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
|
|||||||
<OrderedList>
|
<OrderedList>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
确保 <code>adb</code> 命令可用。
|
确保 <Code>adb</Code> 命令可用。
|
||||||
</Text>
|
</Text>
|
||||||
<Text>
|
<Text>
|
||||||
💡 如果没有,可以
|
💡 如果没有,可以
|
||||||
<Link href="https://scoop.sh/#/apps?q=adb" isExternal>
|
<ExtLink href="https://scoop.sh/#/apps?q=adb">
|
||||||
使用 Scoop 安装 <ExternalLinkIcon />
|
使用 Scoop 安装 <ExternalLinkIcon />
|
||||||
</Link>
|
</ExtLink>
|
||||||
。
|
。
|
||||||
</Text>
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
@@ -134,6 +143,11 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
|
|||||||
</Heading>
|
</Heading>
|
||||||
<AccordionPanel pb={4}>
|
<AccordionPanel pb={4}>
|
||||||
<OrderedList>
|
<OrderedList>
|
||||||
|
<ListItem>
|
||||||
|
<Text>
|
||||||
|
确保 <Code>adb</Code> 命令可用。
|
||||||
|
</Text>
|
||||||
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>将安卓设备连接到电脑,并允许调试。</Text>
|
<Text>将安卓设备连接到电脑,并允许调试。</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function AppRoot() {
|
|||||||
</Tab>
|
</Tab>
|
||||||
<Tab>
|
<Tab>
|
||||||
<Icon as={MdQuestionAnswer} mr="1" />
|
<Icon as={MdQuestionAnswer} mr="1" />
|
||||||
<chakra.span>问答</chakra.span>
|
<chakra.span>答疑</chakra.span>
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabList>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Heading } from '@chakra-ui/react';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export interface Header3Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header3({ children, className, id }: Header3Props) {
|
|
||||||
return (
|
|
||||||
<Heading
|
|
||||||
as="h3"
|
|
||||||
id={id}
|
|
||||||
className={className}
|
|
||||||
pt={3}
|
|
||||||
pb={1}
|
|
||||||
borderBottom={'1px solid'}
|
|
||||||
borderColor="gray.300"
|
|
||||||
color="gray.800"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Heading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Heading } from '@chakra-ui/react';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export interface Header4Props {
|
|
||||||
children: React.ReactNode;
|
|
||||||
id?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Header4({ children, className, id }: Header4Props) {
|
|
||||||
return (
|
|
||||||
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md">
|
|
||||||
{children}
|
|
||||||
</Heading>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
42
src/components/HelpText/Headers.tsx
Normal file
42
src/components/HelpText/Headers.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { Heading } from '@chakra-ui/react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface HeaderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
id?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header3({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Heading
|
||||||
|
as="h3"
|
||||||
|
id={id}
|
||||||
|
className={className}
|
||||||
|
pt={3}
|
||||||
|
pb={1}
|
||||||
|
borderBottom={'1px solid'}
|
||||||
|
borderColor="gray.300"
|
||||||
|
color="gray.800"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header4({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Heading as="h4" id={id} className={className} pt={3} pb={1} color="gray.700" size="md">
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header5({ children, className, id }: HeaderProps) {
|
||||||
|
return (
|
||||||
|
<Heading as="h5" id={id} className={className} pt={3} pb={1} color="gray.700" size="sm">
|
||||||
|
{children}
|
||||||
|
</Heading>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,7 +35,9 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
|
|||||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||||
</Center>
|
</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}>
|
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||||
{children}
|
{children}
|
||||||
</Flex>
|
</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 {
|
export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||||
DECRYPT = 'DECRYPT',
|
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',
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
86
src/decrypt-worker/decipher/QQMusic.ts
Normal file
86
src/decrypt-worker/decipher/QQMusic.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
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})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseFooter(buffer: Uint8Array): { size: number; ekey?: undefined | string } {
|
||||||
|
const footer = QMCFooter.parse(buffer.subarray(buffer.byteLength - 1024));
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
const { size, ekey } = footer;
|
||||||
|
footer.free();
|
||||||
|
return { size, ekey };
|
||||||
|
}
|
||||||
|
|
||||||
|
// No footer, and we don't accept user key:
|
||||||
|
if (!this.useUserKey) {
|
||||||
|
throw new UnsupportedSourceFile('Not QMC2 File');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { size: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||||
|
const footer = this.parseFooter(buffer.subarray(buffer.byteLength - 1024));
|
||||||
|
const ekey = this.useUserKey ? options.qmc2Key : footer.ekey;
|
||||||
|
if (!ekey) {
|
||||||
|
throw new Error('EKey required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const qmc2 = new QMC2(ekey);
|
||||||
|
const audioBuffer = buffer.slice(0, buffer.byteLength - footer.size);
|
||||||
|
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 {
|
export interface DecryptCommandOptions {
|
||||||
|
fileName: string;
|
||||||
qmc2Key?: string;
|
qmc2Key?: string;
|
||||||
kwm2key?: string;
|
kwm2key?: string;
|
||||||
|
qingTingAndroidKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DecryptCommandPayload {
|
export interface DecryptCommandPayload {
|
||||||
@@ -8,3 +10,25 @@ export interface DecryptCommandPayload {
|
|||||||
blobURI: string;
|
blobURI: string;
|
||||||
options: DecryptCommandOptions;
|
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,13 @@
|
|||||||
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
|
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
||||||
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
|
src instanceof Blob ? await src.arrayBuffer() : src;
|
||||||
|
export const toBlob = (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
|
||||||
|
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 { WorkerServerBus } from '~/util/WorkerEventBus';
|
||||||
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
|
||||||
|
import { getUmcVersion } from '@unlock-music/crypto';
|
||||||
|
|
||||||
import { getSDKVersion } from '@jixun/libparakeet';
|
import { workerDecryptHandler } from './worker/decrypt.ts';
|
||||||
|
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
||||||
import { workerDecryptHandler } from './worker/handler/decrypt';
|
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
||||||
|
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
||||||
|
|
||||||
const bus = new WorkerServerBus();
|
const bus = new WorkerServerBus();
|
||||||
onmessage = bus.onmessage;
|
onmessage = bus.onmessage;
|
||||||
|
|
||||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler);
|
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.
|
// 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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react';
|
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
||||||
import { Header4 } from '~/components/HelpText/Header4';
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
import { VQuote } from '~/components/HelpText/VQuote';
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||||
import { HiWord } from '~/components/HelpText/HiWord';
|
import { HiWord } from '~/components/HelpText/HiWord';
|
||||||
@@ -15,9 +15,6 @@ export function KuwoFAQ() {
|
|||||||
<SegmentTryOfficialPlayer />
|
<SegmentTryOfficialPlayer />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
|
||||||
<chakra.strong>2、检查您的平台</chakra.strong>
|
|
||||||
</Text>
|
|
||||||
<Text>
|
<Text>
|
||||||
日前,仅<HiWord>手机客户端</HiWord>下载的
|
日前,仅<HiWord>手机客户端</HiWord>下载的
|
||||||
<VQuote>
|
<VQuote>
|
||||||
@@ -37,7 +34,12 @@ export function KuwoFAQ() {
|
|||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<Flex flexDir="column">
|
<Flex flexDir="column">
|
||||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
||||||
<Text>请注意:项目组不提倡使用第三方修改版应用亦不会提供,使用前请自行评估风险。</Text>
|
<Text>
|
||||||
|
<strong>注意</strong>:已知部分第三方修改版会破坏密钥写入功能,导致无法提取密钥。
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
<strong>注意</strong>:项目组不提倡使用、也不提供第三方修改版。使用前请自行评估风险。
|
||||||
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ExternalLinkIcon } from '@chakra-ui/icons';
|
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||||
import { Link, Text } from '@chakra-ui/react';
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
import { Header4 } from '~/components/HelpText/Header4';
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
|
import { VQuote } from '~/components/HelpText/VQuote';
|
||||||
import { ProjectIssue } from '~/components/ProjectIssue';
|
import { ProjectIssue } from '~/components/ProjectIssue';
|
||||||
|
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
|
||||||
|
|
||||||
export function OtherFAQ() {
|
export function OtherFAQ() {
|
||||||
return (
|
return (
|
||||||
@@ -9,18 +11,127 @@ export function OtherFAQ() {
|
|||||||
<Header4>解密后没有封面等信息</Header4>
|
<Header4>解密后没有封面等信息</Header4>
|
||||||
<Text>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</Text>
|
<Text>该项目进行解密处理。如果加密前的资源没有内嵌元信息或封面,解密的文件也没有。</Text>
|
||||||
<Text>请使用第三方工具进行编辑或管理元信息。</Text>
|
<Text>请使用第三方工具进行编辑或管理元信息。</Text>
|
||||||
<Header4>如何批量下载</Header4>
|
|
||||||
|
<Header4>批量下载</Header4>
|
||||||
<Text>
|
<Text>
|
||||||
暂时没有实现,不过你可以在 <ProjectIssue id={34} title="[UI] 全部下载功能" /> 以及{' '}
|
{'暂时没有实现,不过你可以在 '}
|
||||||
<ProjectIssue id={43} title="批量下载" /> 追踪该问题。
|
<ProjectIssue id={34} title="[UI] 全部下载功能" />
|
||||||
|
{' 以及 '}
|
||||||
|
<ProjectIssue id={43} title="批量下载" />
|
||||||
|
{' 追踪该问题。'}
|
||||||
</Text>
|
</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>
|
<Header4>有更多问题?</Header4>
|
||||||
<Text>
|
<Text>
|
||||||
{'欢迎进入 '}
|
{'欢迎进入 '}
|
||||||
<Link href={'https://t.me/unlock_music_chat'} isExternal>
|
<ExtLink href={'https://t.me/unlock_music_chat'}>Telegram “音乐解锁-交流” 交流群</ExtLink>
|
||||||
Telegram “音乐解锁-交流” 交流群
|
|
||||||
<ExternalLinkIcon />
|
|
||||||
</Link>
|
|
||||||
{' 一起探讨。'}
|
{' 一起探讨。'}
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,43 +1,159 @@
|
|||||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react';
|
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box } from '@chakra-ui/react';
|
||||||
import { Header4 } from '~/components/HelpText/Header4';
|
import { Alert, AlertIcon, Container, Flex, ListItem, Text, UnorderedList } from '@chakra-ui/react';
|
||||||
|
import { Header4 } from '~/components/HelpText/Headers';
|
||||||
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
|
||||||
import { QMCv2AllInstructions } from '~/features/settings/panels/QMCv2/QMCv2AllInstructions';
|
|
||||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||||
|
import { ExtLink } from '~/components/ExtLink';
|
||||||
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
|
import { InstructionsIOS } from '~/features/settings/panels/QMCv2/InstructionsIOS';
|
||||||
|
import { InstructionsMac } from '~/features/settings/panels/QMCv2/InstructionsMac';
|
||||||
|
|
||||||
export function QQMusicFAQ() {
|
export function QQMusicFAQ() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header4>解锁失败</Header4>
|
<Header4>解锁失败</Header4>
|
||||||
<List spacing={2}>
|
|
||||||
<ListItem>
|
|
||||||
<SegmentTryOfficialPlayer />
|
<SegmentTryOfficialPlayer />
|
||||||
|
<Text>重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。</Text>
|
||||||
|
<Text>
|
||||||
|
部分平台获取的加密文件未包含密钥。选择你<strong>下载文件时</strong>使用的客户端来查看说明。
|
||||||
|
</Text>
|
||||||
|
<Accordion allowToggle my={2}>
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
Windows
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
|
<Text>
|
||||||
|
目前 Windows 客户端 19.51 或更低版本下载的歌曲文件无需密钥,其余平台的官方正式版本均需要提取密钥。
|
||||||
|
</Text>
|
||||||
|
<Text>你可以通过下方的链接获取 QQ 音乐 Windows 客户端 v19.51 的安装程序:</Text>
|
||||||
|
<UnorderedList pl={3}>
|
||||||
|
<ListItem>
|
||||||
|
<Text>
|
||||||
|
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
|
||||||
|
<code>qq.com</code> 官方下载地址(推荐)
|
||||||
|
</ExtLink>
|
||||||
|
</Text>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>
|
<Text>
|
||||||
<chakra.strong>2、检查您的平台</chakra.strong>
|
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
|
||||||
|
<code>Archive.org</code> 存档
|
||||||
|
</ExtLink>
|
||||||
</Text>
|
</Text>
|
||||||
<Text>日前,仅Windows客户端下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。</Text>
|
</ListItem>
|
||||||
|
</UnorderedList>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
Mac
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
|
<Container p={2}>
|
||||||
|
<Alert status="warning" borderRadius={5}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Flex flexDir="column">
|
||||||
|
<Text>Mac 需要降级到 8.8.0 或以下版本。</Text>
|
||||||
|
<Text>
|
||||||
|
<ExtLink href="https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg">
|
||||||
|
<code>Archive.org</code> 存档
|
||||||
|
</ExtLink>
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<SegmentKeyImportInstructions
|
||||||
|
tab="QMCv2 密钥"
|
||||||
|
keyInstructionText="查看密钥提取说明:"
|
||||||
|
clientInstructions={
|
||||||
|
<Box p={2}>
|
||||||
|
<InstructionsMac />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
安卓 (Android)
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
|
<Container p={2}>
|
||||||
|
<Alert status="warning" borderRadius={5}>
|
||||||
|
<AlertIcon />
|
||||||
|
<Flex flexDir="column">
|
||||||
|
<Text>安卓提取密钥需要 root 特权,建议用电脑操作。</Text>
|
||||||
|
</Flex>
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Text>QQ 音乐官方版本需要提取密钥才能解密。</Text>
|
||||||
|
<Text>
|
||||||
|
你也可以尝试使用【QQ 音乐简洁版】或 OEM 定制版(如小米、魅族定制版)。简洁、定制版本目前不需要提取密钥。
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<SegmentKeyImportInstructions
|
||||||
|
tab="QMCv2 密钥"
|
||||||
|
keyInstructionText="查看密钥提取说明:"
|
||||||
|
clientInstructions={
|
||||||
|
<Box p={2}>
|
||||||
|
<AndroidADBPullInstruction dir="/data/data/com.tencent.qqmusic/databases" file="player_process_db" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem>
|
||||||
|
<h2>
|
||||||
|
<AccordionButton>
|
||||||
|
<Box as="span" flex="1" textAlign="left">
|
||||||
|
iOS (iPhone, iPad)
|
||||||
|
</Box>
|
||||||
|
<AccordionIcon />
|
||||||
|
</AccordionButton>
|
||||||
|
</h2>
|
||||||
|
<AccordionPanel pb={4}>
|
||||||
<Container p={2}>
|
<Container p={2}>
|
||||||
<Alert status="warning" borderRadius={5}>
|
<Alert status="warning" borderRadius={5}>
|
||||||
<AlertIcon />
|
<AlertIcon />
|
||||||
<Flex flexDir="column">
|
<Flex flexDir="column">
|
||||||
<Text>iOS 用户提取歌曲困难,建议换用电脑操作;</Text>
|
<Text>iOS 用户提取歌曲困难,建议换用电脑操作;</Text>
|
||||||
<Text>安卓用户提取密钥需要root,也建议用电脑操作。</Text>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Alert>
|
</Alert>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
||||||
<Container p={2} pt={0}>
|
<SegmentKeyImportInstructions
|
||||||
<Alert status="info" borderRadius={5}>
|
tab="QMCv2 密钥"
|
||||||
<AlertIcon />
|
keyInstructionText="查看密钥提取说明:"
|
||||||
重复下载同一首的歌曲不重复扣下载配额,但是同一首歌的两个版本会重复扣下载配额,请仔细分辨。
|
clientInstructions={
|
||||||
</Alert>
|
<Box p={2}>
|
||||||
</Container>
|
<InstructionsIOS />
|
||||||
|
</Box>
|
||||||
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2AllInstructions />} />
|
}
|
||||||
</ListItem>
|
/>
|
||||||
</List>
|
</AccordionPanel>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ export function SegmentAddKeyDropdown() {
|
|||||||
ml="2"
|
ml="2"
|
||||||
borderTopLeftRadius={0}
|
borderTopLeftRadius={0}
|
||||||
borderBottomLeftRadius={0}
|
borderBottomLeftRadius={0}
|
||||||
isDisabled
|
pointerEvents="none"
|
||||||
css={{ ':disabled': { opacity: 1 } }}
|
aria-label="下拉按钮"
|
||||||
aria-label="示例按钮"
|
|
||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import { MdFileUpload } from 'react-icons/md';
|
|||||||
export interface SegmentKeyImportInstructionsProps {
|
export interface SegmentKeyImportInstructionsProps {
|
||||||
clientInstructions: React.ReactNode;
|
clientInstructions: React.ReactNode;
|
||||||
tab: string;
|
tab: string;
|
||||||
|
keyInstructionText?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SegmentKeyImportInstructions({ clientInstructions, tab }: SegmentKeyImportInstructionsProps) {
|
export function SegmentKeyImportInstructions({
|
||||||
|
clientInstructions,
|
||||||
|
tab,
|
||||||
|
keyInstructionText = '选择你的客户端平台来查看密钥提取说明:',
|
||||||
|
}: SegmentKeyImportInstructionsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>导入密钥可以参考下面的步骤:</Text>
|
<Text>导入密钥可以参考下面的步骤:</Text>
|
||||||
@@ -33,7 +38,7 @@ export function SegmentKeyImportInstructions({ clientInstructions, tab }: Segmen
|
|||||||
</Flex>
|
</Flex>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
<ListItem>
|
<ListItem>
|
||||||
<Text>选择你的客户端平台来查看密钥提取说明:</Text>
|
<Text>{keyInstructionText}</Text>
|
||||||
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
|
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
|
||||||
{clientInstructions}
|
{clientInstructions}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Text, chakra } from '@chakra-ui/react';
|
import { Alert, AlertIcon, Container } from '@chakra-ui/react';
|
||||||
|
|
||||||
export function SegmentTryOfficialPlayer() {
|
export function SegmentTryOfficialPlayer() {
|
||||||
return (
|
return (
|
||||||
<>
|
<Container p={2} my={2} pt={0}>
|
||||||
<Text>
|
<Alert status="info" borderRadius={5}>
|
||||||
<chakra.strong>1、请检查您的文件</chakra.strong>
|
<AlertIcon />
|
||||||
</Text>
|
尝试用下载音乐的设备播放一次看看,如果官方客户端都无法播放,那解锁肯定会失败哦。
|
||||||
<Text>尝试用下载音乐的设备播放一次看看,如果官方客户端都无法播放,那解锁肯定会失败哦。</Text>
|
</Alert>
|
||||||
</>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
|
|
||||||
export interface FileErrorProps {
|
export interface FileErrorProps {
|
||||||
@@ -18,11 +18,12 @@ export function FileError({ error, code }: FileErrorProps) {
|
|||||||
<Box>
|
<Box>
|
||||||
<Text>
|
<Text>
|
||||||
<chakra.span>
|
<chakra.span>
|
||||||
解密错误:<chakra.span color="red.700">{errorSummary}</chakra.span>
|
解密错误:
|
||||||
|
<chakra.span color="red.700">{errorSummary}</chakra.span>
|
||||||
</chakra.span>
|
</chakra.span>
|
||||||
{error && (
|
{error && (
|
||||||
<Button ml="2" onClick={onToggle} type="button">
|
<Button ml="2" onClick={onToggle} type="button">
|
||||||
详细
|
诊断信息
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function FileRow({ id, file }: FileRowProps) {
|
|||||||
</WrapItem>
|
</WrapItem>
|
||||||
<WrapItem>
|
<WrapItem>
|
||||||
{file.decrypted && (
|
{file.decrypted && (
|
||||||
<Link isExternal href={file.decrypted} download={decryptedName}>
|
<Link href={file.decrypted} download={decryptedName}>
|
||||||
<Button as="span">下载</Button>
|
<Button as="span">下载</Button>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
|
|
||||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||||
import type { RootState } from '~/store';
|
import type { RootState } from '~/store';
|
||||||
|
|
||||||
import type { DecryptionResult } from '~/decrypt-worker/constants';
|
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
||||||
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
|
import type {
|
||||||
import { decryptionQueue } from '~/decrypt-worker/client';
|
DecryptCommandOptions,
|
||||||
|
FetchMusicExNamePayload,
|
||||||
|
ParseKuwoHeaderPayload,
|
||||||
|
ParseKuwoHeaderResponse,
|
||||||
|
} from '~/decrypt-worker/types';
|
||||||
|
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
||||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||||
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector';
|
import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector';
|
||||||
|
|
||||||
export enum ProcessState {
|
export enum ProcessState {
|
||||||
QUEUED = 'QUEUED',
|
QUEUED = 'QUEUED',
|
||||||
@@ -43,8 +48,9 @@ export interface FileListingState {
|
|||||||
files: Record<string, DecryptedAudioFile>;
|
files: Record<string, DecryptedAudioFile>;
|
||||||
displayMode: ListingMode;
|
displayMode: ListingMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: FileListingState = {
|
const initialState: FileListingState = {
|
||||||
files: Object.create(null),
|
files: {},
|
||||||
displayMode: ListingMode.LIST,
|
displayMode: ListingMode.LIST,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,17 +70,21 @@ export const processFile = createAsyncThunk<
|
|||||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const fileHeader = await fetch(file.raw, {
|
const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([
|
||||||
headers: {
|
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
|
||||||
Range: 'bytes=0-1023',
|
blobURI: file.raw,
|
||||||
},
|
}),
|
||||||
})
|
workerClientBus.request<ParseKuwoHeaderResponse, ParseKuwoHeaderPayload>(
|
||||||
.then((r) => r.blob())
|
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
|
||||||
.then((r) => r.arrayBuffer());
|
{ blobURI: file.raw },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
const options: DecryptCommandOptions = {
|
const options: DecryptCommandOptions = {
|
||||||
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
|
fileName: file.fileName,
|
||||||
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
|
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
||||||
|
kwm2key: selectKWMv2Key(state, kuwoHdr),
|
||||||
|
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
||||||
};
|
};
|
||||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
chakra,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
|
chakra,
|
||||||
Flex,
|
Flex,
|
||||||
HStack,
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
@@ -19,9 +19,9 @@ import {
|
|||||||
TabPanels,
|
TabPanels,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
VStack,
|
|
||||||
useBreakpointValue,
|
useBreakpointValue,
|
||||||
useToast,
|
useToast,
|
||||||
|
VStack,
|
||||||
} from '@chakra-ui/react';
|
} from '@chakra-ui/react';
|
||||||
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
@@ -30,10 +30,12 @@ import { useAppDispatch, useAppSelector } from '~/hooks';
|
|||||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||||
import { selectIsSettingsNotSaved } from './settingsSelector';
|
import { selectIsSettingsNotSaved } from './settingsSelector';
|
||||||
|
import { PanelQingTing } from './panels/PanelQingTing';
|
||||||
|
|
||||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
||||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||||
|
{ name: '蜻蜓 FM', Tab: PanelQingTing },
|
||||||
{
|
{
|
||||||
name: '其它/待定',
|
name: '其它/待定',
|
||||||
Tab: () => <Text>这里空空如也~</Text>,
|
Tab: () => <Text>这里空空如也~</Text>,
|
||||||
@@ -143,7 +145,7 @@ export function Settings() {
|
|||||||
onClick={handleResetSettings}
|
onClick={handleResetSettings}
|
||||||
colorScheme="red"
|
colorScheme="red"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
title="放弃未储存的更改,将设定还原为储存前的状态。"
|
title="放弃未储存的更改,将设定还原未储存前的状态。"
|
||||||
aria-label="放弃未储存的更改"
|
aria-label="放弃未储存的更改"
|
||||||
/>
|
/>
|
||||||
<Button onClick={handleApplySettings}>保存</Button>
|
<Button onClick={handleApplySettings}>保存</Button>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { objectify } from 'radash';
|
|||||||
|
|
||||||
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
||||||
src: P,
|
src: P,
|
||||||
make: (k: keyof P, v: P[keyof P]) => null | S
|
make: (k: keyof P, v: P[keyof P]) => null | S,
|
||||||
): S[] {
|
): S[] {
|
||||||
const result: S[] = [];
|
const result: S[] = [];
|
||||||
for (const [key, value] of Object.entries(src)) {
|
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 qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
|
||||||
export const qmc2ProductionToStaging = (
|
export const qmc2ProductionToStaging = (
|
||||||
key: keyof ProductionQMCv2Keys,
|
key: keyof ProductionQMCv2Keys,
|
||||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
|
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys],
|
||||||
): StagingQMCv2Key => {
|
): StagingQMCv2Key => {
|
||||||
return {
|
return {
|
||||||
id: nanoid(),
|
id: nanoid(),
|
||||||
@@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = (
|
|||||||
|
|
||||||
export interface StagingKWMv2Key {
|
export interface StagingKWMv2Key {
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* Resource ID
|
||||||
|
*/
|
||||||
rid: string;
|
rid: string;
|
||||||
|
/**
|
||||||
|
* Quality String
|
||||||
|
*/
|
||||||
quality: string;
|
quality: string;
|
||||||
ekey: string;
|
ekey: string;
|
||||||
}
|
}
|
||||||
@@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
|
|||||||
|
|
||||||
return { rid, quality };
|
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 kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||||
export const kwm2ProductionToStaging = (
|
export const kwm2ProductionToStaging = (
|
||||||
key: keyof ProductionKWMv2Keys,
|
key: keyof ProductionKWMv2Keys,
|
||||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
|
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys],
|
||||||
): null | StagingKWMv2Key => {
|
): null | StagingKWMv2Key => {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
|
|
||||||
const parsed = parseKwm2ProductionKey(key);
|
const parsed = parseKwm2ProductionKey(key);
|
||||||
if (!parsed) return null;
|
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 { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
||||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||||
import { InstructionsPC } from './InstructionsPC';
|
import { InstructionsPC } from './InstructionsPC';
|
||||||
|
import { InstructionsIOS } from './InstructionsIOS';
|
||||||
|
|
||||||
export function KWMv2AllInstructions() {
|
export function KWMv2AllInstructions() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab>安卓</Tab>
|
<Tab>安卓</Tab>
|
||||||
|
<Tab>iOS</Tab>
|
||||||
<Tab>Windows</Tab>
|
<Tab>Windows</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels flex={1} overflow="auto">
|
<TabPanels flex={1} overflow="auto">
|
||||||
@@ -16,6 +18,9 @@ export function KWMv2AllInstructions() {
|
|||||||
file="cn.kuwo.player.mmkv.defaultconfig"
|
file="cn.kuwo.player.mmkv.defaultconfig"
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel>
|
||||||
|
<InstructionsIOS />
|
||||||
|
</TabPanel>
|
||||||
<TabPanel>
|
<TabPanel>
|
||||||
<InstructionsPC />
|
<InstructionsPC />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||||
|
|
||||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||||
import { MMKVParser } from '~/util/MMKVParser';
|
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
|
||||||
|
|
||||||
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
|
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
|
||||||
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
||||||
@@ -41,9 +41,11 @@ export function PanelKWMv2Key() {
|
|||||||
const handleSecretImport = async (file: File) => {
|
const handleSecretImport = async (file: File) => {
|
||||||
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
|
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
|
||||||
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
|
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
|
||||||
const fileBuffer = await file.arrayBuffer();
|
keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||||
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer));
|
} else if (/kw_ekey/.test(file.name)) {
|
||||||
|
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (keys?.length === 0) {
|
if (keys?.length === 0) {
|
||||||
toast({
|
toast({
|
||||||
title: '未导入密钥',
|
title: '未导入密钥',
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
ButtonGroup,
|
ButtonGroup,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Flex,
|
Flex,
|
||||||
HStack,
|
|
||||||
Heading,
|
Heading,
|
||||||
|
HStack,
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
List,
|
List,
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
MenuDivider,
|
MenuDivider,
|
||||||
MenuItem,
|
MenuItem,
|
||||||
MenuList,
|
MenuList,
|
||||||
|
Select,
|
||||||
Text,
|
Text,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
useToast,
|
useToast,
|
||||||
@@ -28,15 +29,17 @@ import { InfoOutlineIcon } from '@chakra-ui/icons';
|
|||||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||||
import { StagingQMCv2Key } from '../keyFormats';
|
import { StagingQMCv2Key } from '../keyFormats';
|
||||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
|
||||||
import { MMKVParser } from '~/util/MMKVParser';
|
import { parseAndroidQmEKey } from '~/util/mmkv/qm';
|
||||||
import { getFileName } from '~/util/pathHelper';
|
import { getFileName } from '~/util/pathHelper';
|
||||||
import { QMCv2AllInstructions } from './QMCv2/QMCv2AllInstructions';
|
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
|
||||||
|
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
|
||||||
|
|
||||||
export function PanelQMCv2Key() {
|
export function PanelQMCv2Key() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [secretType, setSecretType] = useState<'qm' | 'douban'>('qm');
|
||||||
|
|
||||||
const addKey = () => dispatch(qmc2AddKey());
|
const addKey = () => dispatch(qmc2AddKey());
|
||||||
const clearAll = () => dispatch(qmc2ClearKeys());
|
const clearAll = () => dispatch(qmc2ClearKeys());
|
||||||
@@ -51,16 +54,16 @@ export function PanelQMCv2Key() {
|
|||||||
|
|
||||||
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
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();
|
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||||
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
qmc2Keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
|
||||||
if (!qmc2Keys) {
|
if (!qmc2Keys) {
|
||||||
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
alert(`不是支持的 SQLite 数据库文件。`);
|
||||||
return;
|
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 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 }));
|
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +99,8 @@ export function PanelQMCv2Key() {
|
|||||||
</Heading>
|
</Heading>
|
||||||
|
|
||||||
<Text>
|
<Text>
|
||||||
QQ 音乐目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS
|
QQ 音乐、豆瓣 FM 目前采用的加密方案(QMCv2)。在使用「QQ 音乐」安卓、Mac 或 iOS 客户端,以及在使用「豆瓣
|
||||||
客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
|
FM」安卓客户端的情况下,其「离线加密文件」对应的「密钥」储存在独立的数据库文件内。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<HStack pb={2} pt={2}>
|
<HStack pb={2} pt={2}>
|
||||||
@@ -155,16 +158,28 @@ export function PanelQMCv2Key() {
|
|||||||
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
{qmc2Keys.length === 0 && <Text>还没有密钥。</Text>}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<ImportSecretModal
|
<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}
|
show={showImportModal}
|
||||||
onClose={() => setShowImportModal(false)}
|
onClose={() => setShowImportModal(false)}
|
||||||
onImport={handleSecretImport}
|
onImport={handleSecretImport}
|
||||||
>
|
>
|
||||||
<QMCv2AllInstructions />
|
{secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
|
||||||
|
{secretType === 'douban' && <QMCv2DoubanAllInstructions />}
|
||||||
</ImportSecretModal>
|
</ImportSecretModal>
|
||||||
</Flex>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
import { Heading, Text, Code, Kbd, OrderedList, ListItem } from '@chakra-ui/react';
|
import { Heading, Text, Code, Kbd, OrderedList, ListItem, Link } from '@chakra-ui/react';
|
||||||
import { FilePathBlock } from '~/components/FilePathBlock';
|
import { FilePathBlock } from '~/components/FilePathBlock';
|
||||||
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
import { MacCommandKey } from '~/components/Key/MacCommandKey';
|
||||||
import { ShiftKey } from '~/components/Key/ShiftKey';
|
import { ShiftKey } from '~/components/Key/ShiftKey';
|
||||||
|
|
||||||
|
const MAC_CLIENT_URL =
|
||||||
|
'https://web.archive.org/web/20230903/https://dldir1.qq.com/music/clntupate/mac/QQMusicMac_Mgr.dmg';
|
||||||
|
|
||||||
export function InstructionsMac() {
|
export function InstructionsMac() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>Mac 客户端使用 mmkv 数据库储存密钥。</Text>
|
<Text>Mac 客户端使用 mmkv 数据库储存密钥。</Text>
|
||||||
|
<Text>
|
||||||
|
{'此外,你需要降级到 '}
|
||||||
|
<Link isExternal href={MAC_CLIENT_URL}>
|
||||||
|
2023.09.03 版本的客户端
|
||||||
|
</Link>
|
||||||
|
{'。'}
|
||||||
|
新版本对 mmkv 数据库进行了加密处理。
|
||||||
|
</Text>
|
||||||
<Text>该密钥文件通常存储在下述路径:</Text>
|
<Text>该密钥文件通常存储在下述路径:</Text>
|
||||||
<FilePathBlock>
|
<FilePathBlock>
|
||||||
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
|
~/Library/Containers/com.tencent.QQMusicMac/Data/Library/Application Support/QQMusicMac/mmkv/MMKVStreamEncryptId
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { Text } from '@chakra-ui/react';
|
|||||||
export function InstructionsPC() {
|
export function InstructionsPC() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Text>使用 Windows 客户端下载的文件不需要导入密钥。</Text>
|
<Text>使用 Windows 19.51 或更低版本下载的歌曲文件无需密钥。</Text>
|
||||||
|
<Text>使用 Windows 19.57 或更高版本下载的歌曲文件需要导入密钥,但方法尚未公开。</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 { InstructionsMac } from './InstructionsMac';
|
||||||
import { InstructionsPC } from './InstructionsPC';
|
import { InstructionsPC } from './InstructionsPC';
|
||||||
|
|
||||||
export function QMCv2AllInstructions() {
|
export function QMCv2QQMusicAllInstructions() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TabList>
|
<TabList>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
// TODO: Popup dialog for QingTing instructions
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { debounce } from 'radash';
|
import { debounce } from 'radash';
|
||||||
import { produce } from 'immer';
|
|
||||||
|
|
||||||
import type { AppStore } from '~/store';
|
import type { AppStore } from '~/store';
|
||||||
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
|
||||||
import { enumObject } from '~/util/objects';
|
import { enumObject } from '~/util/objects';
|
||||||
import { getLogger } from '~/util/logUtils';
|
import { getLogger } from '~/util/logUtils';
|
||||||
import { parseKwm2ProductionKey } from './keyFormats';
|
import { parseKwm2ProductionKey } from './keyFormats';
|
||||||
|
import { deepClone } from '~/util/deepClone';
|
||||||
|
|
||||||
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
const DEFAULT_STORAGE_KEY = 'um-react-settings';
|
||||||
|
|
||||||
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
||||||
return produce(settingsSlice.getInitialState().production, (draft) => {
|
const draft = deepClone(settingsSlice.getInitialState().production);
|
||||||
if (settings?.qmc2) {
|
if (settings?.qmc2) {
|
||||||
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
const { allowFuzzyNameSearch, keys } = settings.qmc2;
|
||||||
for (const [k, v] of enumObject(keys)) {
|
for (const [k, v] of enumObject(keys)) {
|
||||||
@@ -33,7 +33,12 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
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) {
|
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));
|
localStorage.setItem(storageKey, JSON.stringify(currentSettings));
|
||||||
getLogger().debug('settings saved');
|
getLogger().debug('settings saved');
|
||||||
}
|
}
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { parseKuwoHeader } from '~/crypto/parseKuwo';
|
|
||||||
import type { RootState } from '~/store';
|
import type { RootState } from '~/store';
|
||||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||||
import { hasOwn } from '~/util/objects';
|
import { hasOwn } from '~/util/objects';
|
||||||
import { kwm2StagingToProductionKey } from './keyFormats';
|
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||||
|
import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||||
|
|
||||||
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
||||||
|
|
||||||
@@ -31,14 +31,16 @@ export const selectQMCv2KeyByFileName = (state: RootState, name: string): string
|
|||||||
return ekey;
|
return ekey;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => {
|
export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse): string | undefined => {
|
||||||
const hdr = parseKuwoHeader(headerView);
|
|
||||||
if (!hdr) {
|
if (!hdr) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const quality = String(hdr.qualityId);
|
||||||
|
const rid = String(hdr.resourceId);
|
||||||
|
|
||||||
const keys = selectFinalKWMv2Keys(state);
|
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;
|
let ekey: string | undefined;
|
||||||
if (hasOwn(keys, lookupKey)) {
|
if (hasOwn(keys, lookupKey)) {
|
||||||
@@ -47,3 +49,6 @@ export const selectKWMv2Key = (state: RootState, headerView: DataView): string |
|
|||||||
|
|
||||||
return ekey;
|
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: {
|
kwm2: {
|
||||||
keys: StagingKWMv2Key[];
|
keys: StagingKWMv2Key[];
|
||||||
};
|
};
|
||||||
|
qtfm: {
|
||||||
|
android: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductionSettings {
|
export interface ProductionSettings {
|
||||||
@@ -34,6 +37,9 @@ export interface ProductionSettings {
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
||||||
};
|
};
|
||||||
|
qtfm: {
|
||||||
|
android: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SettingsState {
|
export interface SettingsState {
|
||||||
@@ -46,10 +52,12 @@ const initialState: SettingsState = {
|
|||||||
staging: {
|
staging: {
|
||||||
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
||||||
kwm2: { keys: [] },
|
kwm2: { keys: [] },
|
||||||
|
qtfm: { android: '' },
|
||||||
},
|
},
|
||||||
production: {
|
production: {
|
||||||
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
||||||
kwm2: { keys: {} },
|
kwm2: { keys: {} },
|
||||||
|
qtfm: { android: '' },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -61,6 +69,7 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
||||||
},
|
},
|
||||||
|
qtfm: staging.qtfm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
||||||
@@ -71,6 +80,7 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
|
|||||||
kwm2: {
|
kwm2: {
|
||||||
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
||||||
},
|
},
|
||||||
|
qtfm: production.qtfm,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const settingsSlice = createSlice({
|
export const settingsSlice = createSlice({
|
||||||
@@ -101,7 +111,7 @@ export const settingsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
qmc2UpdateKey(
|
qmc2UpdateKey(
|
||||||
state,
|
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);
|
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
|
||||||
if (keyItem) {
|
if (keyItem) {
|
||||||
@@ -134,7 +144,7 @@ export const settingsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
kwm2UpdateKey(
|
kwm2UpdateKey(
|
||||||
state,
|
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);
|
const keyItem = state.staging.kwm2.keys.find((item) => item.id === id);
|
||||||
if (keyItem) {
|
if (keyItem) {
|
||||||
@@ -142,6 +152,10 @@ export const settingsSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
|
||||||
|
state.staging.qtfm.android = deviceKey;
|
||||||
|
state.dirty = true;
|
||||||
|
},
|
||||||
kwm2ClearKeys(state) {
|
kwm2ClearKeys(state) {
|
||||||
state.staging.kwm2.keys = [];
|
state.staging.kwm2.keys = [];
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
@@ -183,6 +197,8 @@ export const {
|
|||||||
kwm2ClearKeys,
|
kwm2ClearKeys,
|
||||||
kwm2ImportKeys,
|
kwm2ImportKeys,
|
||||||
|
|
||||||
|
qtfmAndroidUpdateKey,
|
||||||
|
|
||||||
commitStagingChange,
|
commitStagingChange,
|
||||||
discardStagingChanges,
|
discardStagingChanges,
|
||||||
} = settingsSlice.actions;
|
} = settingsSlice.actions;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user