mirror of
https://git.unlock-music.dev/um/um-react.git
synced 2025-05-23 16:27:41 +08:00
Compare commits
13 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
0a820b620b | ||
|
721d947fdb | ||
|
1880220aaa | ||
|
d91e2fffe4 | ||
|
88cfbcd337 | ||
|
e9480ce6a4 | ||
|
a07bcf2575 | ||
|
a40ecc4569 | ||
|
1abfe3498f | ||
|
e69393d1bc | ||
|
19c5d0aab9 | ||
|
baab3057cf | ||
|
c71078f5da |
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@ -0,0 +1,3 @@
|
||||
dist
|
||||
node_modules
|
||||
*.log
|
@ -1,3 +0,0 @@
|
||||
dist/
|
||||
node_modules/
|
||||
coverage/
|
@ -1,27 +0,0 @@
|
||||
/* eslint-env node */
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
varsIgnorePattern: '^_',
|
||||
argsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
46
.gitea/ISSUE_TEMPLATE/50-qqmusic-android.yaml
Normal file
46
.gitea/ISSUE_TEMPLATE/50-qqmusic-android.yaml
Normal file
@ -0,0 +1,46 @@
|
||||
name: QQ 音乐 (安卓)
|
||||
about: 解密使用「QQ 音乐」的「安卓」客户端下载的文件失败时选择该项。
|
||||
title: '[QQ音乐]: 请填写标题'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### 安卓客户端
|
||||
|
||||
你的手机需要 root 权限。注意 root 可能会导致手机保修失效,操作不当可能会导致手机变砖。
|
||||
|
||||
请参考 [um-react](https://um-react.netlify.app/) 上方的答疑区域获取说明。
|
||||
|
||||
此外请使用 Chrome 或 Firefox 浏览器,而非系统自带的浏览器或轻量浏览器,如 Via 浏览器。
|
||||
|
||||
---
|
||||
|
||||
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 错误描述
|
||||
description: |
|
||||
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志
|
||||
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 检查清单
|
||||
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
|
||||
options:
|
||||
- label: 我有填写一个简单易懂的标题
|
||||
required: true
|
||||
- label: 我有阅读上方的说明
|
||||
required: true
|
||||
- label: 我有阅读 um-react 的答疑部分
|
||||
required: true
|
||||
- label: 我的安卓设备已获得 root 权限
|
||||
required: true
|
63
.gitea/ISSUE_TEMPLATE/50-qqmusic.yaml
Normal file
63
.gitea/ISSUE_TEMPLATE/50-qqmusic.yaml
Normal file
@ -0,0 +1,63 @@
|
||||
name: QQ 音乐 (Windows/Mac)
|
||||
about: 解密使用「QQ 音乐」的「Windows」或「Mac」客户端下载的文件失败时选择该项。
|
||||
title: '[QQ音乐]: 请填写标题'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### Windows 客户端
|
||||
|
||||
目前 Windows 客户端仅支持 19.51 或更低版本下载的歌曲文件。
|
||||
|
||||
* [web.archive.org 镜像](https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe)
|
||||
* [通过 Telegram 下载](https://t.me/um_lsr_ch/24)
|
||||
|
||||
安装好客户端后可以加装更新屏蔽更新:
|
||||
|
||||
* [通过 Telegram 下载](https://t.me/um_lsr_ch/6)
|
||||
|
||||
### Mac 客户端
|
||||
|
||||
目前 Mac 客户端仅支持 v8.8.0 或更低版本下载的歌曲文件。
|
||||
|
||||
* [通过 Telegram 下载](https://t.me/um_lsr_ch/21)
|
||||
|
||||
---
|
||||
|
||||
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: 平台
|
||||
description: 你使用的客户端平台是…
|
||||
options:
|
||||
- Windows
|
||||
- Mac
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 错误描述
|
||||
description: |
|
||||
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志
|
||||
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 检查清单
|
||||
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
|
||||
options:
|
||||
- label: 我有填写一个简单易懂的标题
|
||||
required: true
|
||||
- label: 我有阅读上方的说明
|
||||
required: true
|
||||
- label: 我有阅读 um-react 的答疑部分
|
||||
required: true
|
51
.gitea/ISSUE_TEMPLATE/53-kuwo-android.yaml
Normal file
51
.gitea/ISSUE_TEMPLATE/53-kuwo-android.yaml
Normal file
@ -0,0 +1,51 @@
|
||||
name: 酷我音乐 (安卓)
|
||||
about: 解密使用「酷我音乐」的「安卓」客户端下载的文件失败时选择该项。
|
||||
title: '[酷我音乐]: 请填写标题'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
### 酷我音乐 - 安卓客户端
|
||||
|
||||
你的手机需要 root 权限。注意 root 可能会导致手机保修失效,操作不当可能会导致手机变砖。
|
||||
|
||||
请参考 [um-react](https://um-react.netlify.app/) 上方的答疑区域获取说明。
|
||||
|
||||
此外请使用 Chrome 或 Firefox 浏览器,而非系统自带的浏览器或轻量浏览器,如 Via 浏览器。
|
||||
|
||||
※ 如果你使用酷我音乐的是所谓“破解版”,你的问题会被忽略。
|
||||
已知部分“第三方魔改”会破坏密钥写出过程,请从官方渠道安装酷我音乐。
|
||||
|
||||
---
|
||||
|
||||
如果你确定你的客户端版本符合上述描述,并遇到了问题,请继续填写下面的表单。
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 错误描述
|
||||
description: |
|
||||
请详细描述你遇到的问题,例如下载使用的客户端、提供下载后的文件、操作步骤、错误提示等你认为会帮助修正错误的信息。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: 相关日志
|
||||
description: 如果有相关日志,请附上。浏览器日志可以通过 F12 打开开发者工具,在 Console 选项卡中查看。
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 检查清单
|
||||
description: 请检查下方的快速检查清单,确保你已经完成了所有步骤。
|
||||
options:
|
||||
- label: 我有填写一个简单易懂的标题
|
||||
required: true
|
||||
- label: 我有阅读上方的说明
|
||||
required: true
|
||||
- label: 我有阅读 um-react 的答疑部分
|
||||
required: true
|
||||
- label: 我的安卓设备已获得 root 权限
|
||||
required: true
|
||||
- label: 我能使用官方渠道下载的酷我音乐客户端复现该问题
|
||||
required: true
|
4
.gitea/ISSUE_TEMPLATE/99-default.md
Normal file
4
.gitea/ISSUE_TEMPLATE/99-default.md
Normal file
@ -0,0 +1,4 @@
|
||||
---
|
||||
name: '其它'
|
||||
about: '如果你遇到的问题不符合上述模板的描述,请选择此项。'
|
||||
---
|
1
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
1
.gitea/ISSUE_TEMPLATE/config.yml
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
5
.vscode/extensions.json
vendored
5
.vscode/extensions.json
vendored
@ -3,9 +3,6 @@
|
||||
"editorconfig.editorconfig",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"christian-kohler.path-intellisense",
|
||||
"txava.region-marker",
|
||||
"foxundermoon.shell-format",
|
||||
"jock.svg"
|
||||
"foxundermoon.shell-format"
|
||||
]
|
||||
}
|
||||
|
25
Dockerfile
Normal file
25
Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
FROM node:22-slim AS build
|
||||
ENV PNPM_HOME="/p"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable pnpm \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package.json pnpm-lock.yaml .npmrc ./
|
||||
RUN pnpm exec true
|
||||
COPY . .
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
ARG GIT_COMMIT=
|
||||
ARG GIT_COMMIT_FULL=
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM caddy:latest
|
||||
COPY --from=build /app/dist /srv/um-react
|
||||
EXPOSE 80
|
||||
CMD ["caddy", "file-server", "--root", "/srv/um-react"]
|
36
README.MD
36
README.MD
@ -1,6 +1,6 @@
|
||||
# Unlock Music 音乐解锁 (React)
|
||||
|
||||
[](https://ci.unlock-music.dev/um/um-react)
|
||||
[][um-react-actions]
|
||||
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
|
||||
@ -17,7 +17,7 @@
|
||||
[um-vue]: https://git.unlock-music.dev/um/web
|
||||
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
||||
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions
|
||||
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions?workflow=build.yaml
|
||||
|
||||
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
|
||||
|
||||
@ -32,7 +32,8 @@
|
||||
- [x] 网易云音乐 (`.ncm`)
|
||||
- [x] 虾米音乐 (`.xm`)
|
||||
- [x] 酷我音乐 (`.kwm`)
|
||||
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
|
||||
- [x] 酷狗音乐 (`.kgm` / `.vpr` / `.kgg`)
|
||||
- PC / 安卓客户端的 `kgg` 文件需要提供密钥数据库。
|
||||
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
|
||||
- [x] 咪咕音乐格式 (`.mg3d`)
|
||||
- [x] 蜻蜓 FM (`.qta`)
|
||||
@ -58,6 +59,33 @@
|
||||
|
||||
[project-issues]: https://git.unlock-music.dev/um/um-react/issues/new
|
||||
|
||||
## 使用 Docker 构建、部署 (Linux)
|
||||
|
||||
首先克隆仓库并进入目录:
|
||||
|
||||
```sh
|
||||
git clone https://git.unlock-music.dev/um/um-react.git
|
||||
cd um-react
|
||||
```
|
||||
|
||||
构建 Docker 镜像:
|
||||
|
||||
```sh
|
||||
docker build \
|
||||
-t um-react \
|
||||
--build-arg GIT_COMMIT_FULL="$(git describe --long --dirty --tags --always)" \
|
||||
--build-arg GIT_COMMIT="$(git rev-parse --short HEAD)" \
|
||||
.
|
||||
```
|
||||
|
||||
在后台运行 Docker 容器:
|
||||
|
||||
```sh
|
||||
docker run -d -p 8080:80 --name um-react um-react
|
||||
```
|
||||
|
||||
然后访问 `http://localhost:8080` 即可。
|
||||
|
||||
## 开发相关
|
||||
|
||||
从源码运行或编译生产版本,请参考文档「[新手上路](./docs/getting-started.zh.md)」。
|
||||
@ -89,6 +117,8 @@
|
||||
|
||||
- [Unlock Music (Web)](https://git.unlock-music.dev/um/web) - 原始项目
|
||||
- [Unlock Music (Cli)](https://git.unlock-music.dev/um/cli) - 命令行批量处理版
|
||||
- [lib_um_crypto_rust](https://git.unlock-music.dev/um/lib_um_crypto_rust) - 项目引入的解密算法实现
|
||||
- [NPM 包](https://git.unlock-music.dev/um/-/packages/npm/@unlock-music%2Fcrypto)
|
||||
- [um-react (Electron 前端)](https://github.com/CarlGao4/um-react-electron) - 使用 Electron 框架封装的本地可执行文件。
|
||||
- [GitHub 下载](https://github.com/CarlGao4/um-react-electron/releases/latest) | [仓库镜像](https://git.unlock-music.dev/CarlGao4/um-react-electron)
|
||||
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
|
||||
|
43
eslint.config.mjs
Normal file
43
eslint.config.mjs
Normal file
@ -0,0 +1,43 @@
|
||||
import eslint from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import eslintConfigPrettier from 'eslint-config-prettier/flat';
|
||||
import globals from 'globals';
|
||||
|
||||
export default tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactRefresh.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
eslintConfigPrettier,
|
||||
|
||||
{
|
||||
rules: {
|
||||
'react-refresh/only-export-components': 'warn',
|
||||
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
varsIgnorePattern: '^_',
|
||||
argsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
ignores: ['**/dist/', '**/node_modules/', '**/coverage/'],
|
||||
},
|
||||
|
||||
{
|
||||
files: ['scripts/*.mjs'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
81
package.json
81
package.json
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "um-react",
|
||||
"private": true,
|
||||
"version": "0.3.3",
|
||||
"version": "0.4.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
@ -19,56 +19,61 @@
|
||||
"dependencies": {
|
||||
"@chakra-ui/anatomy": "^2.3.4",
|
||||
"@chakra-ui/icons": "^2.2.4",
|
||||
"@chakra-ui/react": "^2.10.4",
|
||||
"@chakra-ui/react": "^2.10.7",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"@unlock-music/crypto": "0.1.2",
|
||||
"framer-motion": "^11.14.4",
|
||||
"nanoid": "^5.0.9",
|
||||
"@reduxjs/toolkit": "^2.6.1",
|
||||
"@unlock-music/crypto": "0.1.9",
|
||||
"framer-motion": "^12.6.2",
|
||||
"nanoid": "^5.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"radash": "^12.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-icons": "^5.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-promise-suspense": "^0.3.4",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"sass": "^1.83.0",
|
||||
"sql.js": "^1.12.0"
|
||||
"sass": "^1.86.0",
|
||||
"sql.js": "^1.13.0",
|
||||
"workbox-build": "^7.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-replace": "^6.0.1",
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.16",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.28.0",
|
||||
"@typescript-eslint/parser": "^8.28.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"@vitest/coverage-v8": "^2.1.8",
|
||||
"@vitest/ui": "^2.1.8",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"@vitest/ui": "^3.0.9",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-prettier": "^10.1.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^25.0.1",
|
||||
"lint-staged": "^15.2.11",
|
||||
"prettier": "^3.4.2",
|
||||
"rollup": "^4.28.1",
|
||||
"simple-git-hooks": "^2.11.1",
|
||||
"terser": "^5.37.0",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vitest": "^2.1.8",
|
||||
"jsdom": "^26.0.0",
|
||||
"lint-staged": "^15.5.0",
|
||||
"prettier": "^3.5.3",
|
||||
"rollup": "^4.38.0",
|
||||
"simple-git-hooks": "^2.12.1",
|
||||
"terser": "^5.39.0",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^6.2.3",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vite-plugin-top-level-await": "^1.5.0",
|
||||
"vite-plugin-wasm": "^3.4.1",
|
||||
"vitest": "^3.0.9",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
|
4139
pnpm-lock.yaml
generated
4139
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -7,8 +7,13 @@ 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();
|
||||
let commitHash = process.env.GIT_COMMIT || 'unknown';
|
||||
try {
|
||||
commitHash = execSync('git rev-parse --short HEAD').toString('utf-8').trim();
|
||||
} catch (e) {
|
||||
console.error('Failed to get commit hash:', e);
|
||||
}
|
||||
|
||||
const pkgJson = JSON.parse(readFileSync(__dirname + '/../package.json', 'utf-8'));
|
||||
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash ?? 'unknown'}` + '\n';
|
||||
const pkgVer = `${pkgJson.version ?? 'unknown'}-${commitHash}` + '\n';
|
||||
writeFileSync(__dirname + '/../dist/version.txt', pkgVer, 'utf-8');
|
||||
|
47
src/components/AddKey.tsx
Normal file
47
src/components/AddKey.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
|
||||
export interface AddKeyProps {
|
||||
addKey: () => void;
|
||||
importKeyFromFile?: () => void;
|
||||
clearKeys?: () => void;
|
||||
}
|
||||
|
||||
export function AddKey({ addKey, importKeyFromFile, clearKeys }: AddKeyProps) {
|
||||
return (
|
||||
<HStack pb={2} pt={2}>
|
||||
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
||||
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
||||
添加一条密钥
|
||||
</Button>
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
||||
<MenuList>
|
||||
{importKeyFromFile && (
|
||||
<MenuItem onClick={importKeyFromFile} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||
从文件导入密钥…
|
||||
</MenuItem>
|
||||
)}
|
||||
{importKeyFromFile && clearKeys && <MenuDivider />}
|
||||
{clearKeys && (
|
||||
<MenuItem color="red" onClick={clearKeys} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||
清空密钥
|
||||
</MenuItem>
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
);
|
||||
}
|
@ -18,11 +18,19 @@ export interface ImportSecretModalProps {
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (file: File) => void;
|
||||
onImport: (file: File) => void|Promise<void>;
|
||||
}
|
||||
|
||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
||||
const handleFileReceived = (files: File[]) => {
|
||||
const promise = onImport(files[0]);
|
||||
if (promise instanceof Promise) {
|
||||
promise.catch(err => {
|
||||
console.error('could not import: ', err);
|
||||
});
|
||||
}
|
||||
return promise;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||
|
@ -2,6 +2,7 @@ export enum DECRYPTION_WORKER_ACTION_NAME {
|
||||
DECRYPT = 'DECRYPT',
|
||||
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
|
||||
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
|
||||
KUGOU_PARSE_HEADER = 'KUGOU_PARSE_HEADER',
|
||||
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
|
||||
VERSION = 'VERSION',
|
||||
}
|
||||
|
@ -1,16 +1,18 @@
|
||||
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
|
||||
import { KuGou } from '@unlock-music/crypto';
|
||||
import { KuGou, KuGouHeader } 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> {
|
||||
async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise<DecipherResult | DecipherOK> {
|
||||
let kgm: KuGou | undefined;
|
||||
let kgmHdr: KuGouHeader | undefined;
|
||||
|
||||
try {
|
||||
kgm = KuGou.from_header(buffer.subarray(0, 0x400));
|
||||
kgmHdr = new KuGouHeader(buffer.subarray(0, 0x400));
|
||||
kgm = KuGou.fromHeaderV5(kgmHdr, options.kugouKey);
|
||||
|
||||
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
|
||||
for (const [block, offset] of chunkBuffer(audioBuffer)) {
|
||||
@ -23,6 +25,7 @@ export class KugouMusicDecipher implements DecipherInstance {
|
||||
data: audioBuffer,
|
||||
};
|
||||
} finally {
|
||||
kgmHdr?.free();
|
||||
kgm?.free();
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ export interface DecryptCommandOptions {
|
||||
fileName: string;
|
||||
qmc2Key?: string;
|
||||
kwm2key?: string;
|
||||
kugouKey?: string;
|
||||
qingTingAndroidKey?: string;
|
||||
}
|
||||
|
||||
@ -24,6 +25,15 @@ export type ParseKuwoHeaderResponse = null | {
|
||||
qualityId: number;
|
||||
};
|
||||
|
||||
export interface ParseKugouHeaderPayload {
|
||||
blobURI: string;
|
||||
}
|
||||
|
||||
export type ParseKugouHeaderResponse = null | {
|
||||
version: number;
|
||||
audioHash: string;
|
||||
};
|
||||
|
||||
export interface GetQingTingFMDeviceKeyPayload {
|
||||
product: string;
|
||||
device: string;
|
||||
|
@ -6,6 +6,7 @@ import { workerDecryptHandler } from './worker/decrypt.ts';
|
||||
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
|
||||
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
|
||||
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
|
||||
import { workerParseKugouHeader } from '~/decrypt-worker/worker/kugou_parse_header.ts';
|
||||
|
||||
const bus = new WorkerServerBus();
|
||||
onmessage = bus.onmessage;
|
||||
@ -14,4 +15,5 @@ bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler)
|
||||
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.KUGOU_PARSE_HEADER, workerParseKugouHeader);
|
||||
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
|
||||
|
23
src/decrypt-worker/worker/kugou_parse_header.ts
Normal file
23
src/decrypt-worker/worker/kugou_parse_header.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {
|
||||
ParseKugouHeaderPayload, ParseKugouHeaderResponse,
|
||||
|
||||
} from '~/decrypt-worker/types.ts';
|
||||
import { KuGouHeader } from '@unlock-music/crypto';
|
||||
|
||||
export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayload): Promise<ParseKugouHeaderResponse> => {
|
||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400));
|
||||
|
||||
let kwm : KuGouHeader | undefined;
|
||||
|
||||
try {
|
||||
kwm = new KuGouHeader(buffer);
|
||||
const { version, audioHash } = kwm;
|
||||
return { version, audioHash };
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
kwm?.free();
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
import { KuwoHeader } from '@unlock-music/crypto';
|
||||
|
||||
export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise<ParseKuwoHeaderResponse> => {
|
||||
export const workerParseKuwoHeader = async ({ blobURI }: ParseKuwoHeaderPayload): Promise<ParseKuwoHeaderResponse> => {
|
||||
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
|
||||
const arrayBuffer = await blob.arrayBuffer();
|
||||
|
||||
|
31
src/faq/KugouFAQ.tsx
Normal file
31
src/faq/KugouFAQ.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
|
||||
import { Header4 } from '~/components/HelpText/Headers';
|
||||
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
|
||||
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
||||
|
||||
export function KugouFAQ() {
|
||||
return (
|
||||
<>
|
||||
<Header4>解锁失败</Header4>
|
||||
<List spacing={2}>
|
||||
<ListItem>
|
||||
<Text>
|
||||
酷狗现在对部分用户推送了 <code>kgg</code> 加密格式(安卓、Windows 客户端)。
|
||||
</Text>
|
||||
<Text>根据平台不同,你需要提取密钥数据库。</Text>
|
||||
|
||||
<Container p={2}>
|
||||
<Alert status="warning" borderRadius={5}>
|
||||
<AlertIcon />
|
||||
<Flex flexDir="column">
|
||||
<Text>安卓用户提取密钥需要 root 权限,或注入文件提供器。</Text>
|
||||
</Flex>
|
||||
</Alert>
|
||||
</Container>
|
||||
|
||||
<SegmentKeyImportInstructions tab="酷狗密钥" clientInstructions={<KugouAllInstructions />} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
}
|
@ -5,13 +5,18 @@ import type { RootState } from '~/store';
|
||||
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
|
||||
import type {
|
||||
DecryptCommandOptions,
|
||||
FetchMusicExNamePayload,
|
||||
FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse,
|
||||
ParseKuwoHeaderPayload,
|
||||
ParseKuwoHeaderResponse,
|
||||
ParseKuwoHeaderResponse
|
||||
} from '~/decrypt-worker/types';
|
||||
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
|
||||
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
|
||||
import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector';
|
||||
import {
|
||||
selectKugouKey,
|
||||
selectKWMv2Key,
|
||||
selectQMCv2KeyByFileName,
|
||||
selectQtfmAndroidKey
|
||||
} from '../settings/settingsSelector';
|
||||
|
||||
export enum ProcessState {
|
||||
QUEUED = 'QUEUED',
|
||||
@ -70,7 +75,7 @@ export const processFile = createAsyncThunk<
|
||||
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
|
||||
};
|
||||
|
||||
const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([
|
||||
const [qmcv2MusicExMediaFile, kuwoHdr, kugouHdr] = await Promise.all([
|
||||
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
|
||||
blobURI: file.raw,
|
||||
}),
|
||||
@ -78,12 +83,17 @@ export const processFile = createAsyncThunk<
|
||||
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
|
||||
{ blobURI: file.raw },
|
||||
),
|
||||
workerClientBus.request<ParseKugouHeaderResponse, ParseKugouHeaderPayload>(
|
||||
DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER,
|
||||
{ blobURI: file.raw },
|
||||
),
|
||||
]);
|
||||
|
||||
const options: DecryptCommandOptions = {
|
||||
fileName: file.fileName,
|
||||
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
|
||||
kwm2key: selectKWMv2Key(state, kuwoHdr),
|
||||
kugouKey: selectKugouKey(state, kugouHdr),
|
||||
qingTingAndroidKey: selectQtfmAndroidKey(state),
|
||||
};
|
||||
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
|
||||
|
@ -24,17 +24,19 @@ import {
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { PanelQMCv2Key } from './panels/PanelQMCv2Key';
|
||||
import { useState } from 'react';
|
||||
import { useState, type FC } from 'react';
|
||||
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
||||
import { useAppDispatch, useAppSelector } from '~/hooks';
|
||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||
import { selectIsSettingsNotSaved } from './settingsSelector';
|
||||
import { PanelQingTing } from './panels/PanelQingTing';
|
||||
import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
|
||||
|
||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
||||
const TABS: { name: string; Tab: FC }[] = [
|
||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||
{ name: 'KGG 密钥', Tab: PanelKGGKey },
|
||||
{ name: '蜻蜓 FM', Tab: PanelQingTing },
|
||||
{
|
||||
name: '其它/待定',
|
||||
|
@ -14,6 +14,7 @@ export function productionKeyToStaging<S, P extends Record<string, unknown>>(
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function stagingKeyToProduction<S, P>(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P {
|
||||
return objectify(src, toKey, toValue) as P;
|
||||
}
|
||||
@ -41,7 +42,6 @@ export const qmc2ProductionToStaging = (
|
||||
};
|
||||
|
||||
// KWMv2 (KuWo)
|
||||
|
||||
export interface StagingKWMv2Key {
|
||||
id: string;
|
||||
/**
|
||||
@ -64,7 +64,7 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
|
||||
|
||||
return { rid, quality };
|
||||
};
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`;
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/\D/g, '')}`;
|
||||
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||
export const kwm2ProductionToStaging = (
|
||||
key: keyof ProductionKWMv2Keys,
|
||||
@ -78,3 +78,21 @@ export const kwm2ProductionToStaging = (
|
||||
|
||||
return { id: nanoid(), rid, quality, ekey: value };
|
||||
};
|
||||
|
||||
// KuGou (kgg, kgm v5)
|
||||
export interface StagingKugouKey {
|
||||
id: string;
|
||||
audioHash: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export type ProductionKugouKey = Record<string /* audioHash */, string /* ekey */>;
|
||||
export const kugouStagingToProductionKey = (key: StagingKugouKey) => key.audioHash.normalize();
|
||||
export const kugouStagingToProductionValue = (key: StagingKugouKey) => key.ekey.normalize();
|
||||
export const kugouProductionToStaging = (
|
||||
key: keyof ProductionKugouKey,
|
||||
value: ProductionKugouKey[keyof ProductionKugouKey],
|
||||
): null | StagingKugouKey => {
|
||||
if (typeof value !== 'string') return null;
|
||||
return { id: nanoid(), audioHash: key.normalize(), ekey: value };
|
||||
};
|
||||
|
34
src/features/settings/panels/Kugou/InstructionsPC.tsx
Normal file
34
src/features/settings/panels/Kugou/InstructionsPC.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import { Code, Heading, ListItem, OrderedList, Text } from '@chakra-ui/react';
|
||||
import { FilePathBlock } from '~/components/FilePathBlock.tsx';
|
||||
|
||||
export function InstructionsPC() {
|
||||
return (
|
||||
<>
|
||||
<Text>酷狗的 Windows 客户端使用 <abbr title="SQLite w/ SQLCipher">SQLite</abbr> 数据库储存密钥。</Text>
|
||||
<Text>该密钥文件通常存储在下述路径:</Text>
|
||||
<FilePathBlock>%APPDATA%\KuGou8\KGMusicV3.db</FilePathBlock>
|
||||
|
||||
<Heading as="h3" size="md" mt="4">
|
||||
导入密钥
|
||||
</Heading>
|
||||
<OrderedList>
|
||||
<ListItem>
|
||||
<Text>
|
||||
选中并复制上述的 <Code>KGMusicV3.db</Code> 文件路径
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>点击上方的「文件选择区域」,打开「文件选择框」</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>
|
||||
在「文件名」输入框中粘贴之前复制的 <Code>KGMusicV3.db</Code> 文件路径
|
||||
</Text>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Text>按下「回车键」确认。</Text>
|
||||
</ListItem>
|
||||
</OrderedList>
|
||||
</>
|
||||
);
|
||||
}
|
25
src/features/settings/panels/Kugou/KugouAllInstructions.tsx
Normal file
25
src/features/settings/panels/Kugou/KugouAllInstructions.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
|
||||
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
|
||||
import { InstructionsPC } from './InstructionsPC';
|
||||
|
||||
export function KugouAllInstructions() {
|
||||
return (
|
||||
<>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
<Tab>Windows</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
<TabPanel>
|
||||
<AndroidADBPullInstruction
|
||||
dir="/data/data/com.kugou.android/files/mmkv"
|
||||
file="mggkey_multi_process"
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsPC />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</>
|
||||
);
|
||||
}
|
72
src/features/settings/panels/Kugou/KugouEKeyItem.tsx
Normal file
72
src/features/settings/panels/Kugou/KugouEKeyItem.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
ListItem,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||
import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { memo } from 'react';
|
||||
import { StagingKugouKey } from '../../keyFormats';
|
||||
|
||||
export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateKey = (prop: keyof StagingKugouKey, e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(kugouUpdateKey({ id, field: prop, value: e.target.value }));
|
||||
const deleteKey = () => dispatch(kugouDeleteKey({ id }));
|
||||
|
||||
return (
|
||||
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
|
||||
<HStack>
|
||||
<Text w="2em" textAlign="center">
|
||||
{i + 1}
|
||||
</Text>
|
||||
|
||||
<VStack flex={1}>
|
||||
<HStack flex={1} w="full">
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="音频哈希。不建议手动填写。"
|
||||
value={audioHash}
|
||||
onChange={(e) => updateKey('audioHash', e)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<InputGroup size="xs">
|
||||
<InputLeftElement pr="2">
|
||||
<Icon as={MdVpnKey} />
|
||||
</InputLeftElement>
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="密钥,通常包含 364 或 704 位字符,没有空格。"
|
||||
value={ekey}
|
||||
onChange={(e) => updateKey('ekey', e)}
|
||||
/>
|
||||
<InputRightElement>
|
||||
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
||||
<code>{ekey.length || '?'}</code>
|
||||
</Text>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</VStack>
|
||||
|
||||
<IconButton
|
||||
aria-label="删除该密钥"
|
||||
icon={<Icon as={MdDelete} boxSize={6} />}
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
type="button"
|
||||
onClick={deleteKey}
|
||||
/>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
);
|
||||
});
|
87
src/features/settings/panels/PanelKGGKey.tsx
Normal file
87
src/features/settings/panels/PanelKGGKey.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { Box, Flex, Heading, List, Text, useToast } from '@chakra-ui/react';
|
||||
import { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
|
||||
import { kugouAddKey, kugouClearKeys, kugouImportKeys } from '../settingsSlice';
|
||||
import { selectStagingKugouV5Keys } from '../settingsSelector';
|
||||
import type { StagingKugouKey } from '../keyFormats';
|
||||
import { AddKey } from '~/components/AddKey.tsx';
|
||||
import { KugouEKeyItem } from '~/features/settings/panels/Kugou/KugouEKeyItem.tsx';
|
||||
import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
|
||||
import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts';
|
||||
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts';
|
||||
|
||||
export function PanelKGGKey() {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const kugouKeys = useSelector(selectStagingKugouV5Keys);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
const addKey = () => dispatch(kugouAddKey());
|
||||
const clearAll = () => dispatch(kugouClearKeys());
|
||||
const handleSecretImport = async (file: File) => {
|
||||
let keys: Omit<StagingKugouKey, 'id'>[] | null = null;
|
||||
if (/mggkey_multi_process/i.test(file.name)) {
|
||||
keys = parseAndroidKugouMMKV(new DataView(await file.arrayBuffer()));
|
||||
} else if (/^KGMusicV3\.db$/.test(file.name)) {
|
||||
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||
keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer());
|
||||
}
|
||||
|
||||
if (keys?.length === 0) {
|
||||
toast({
|
||||
title: '未导入密钥',
|
||||
description: '选择的密钥数据库文件未发现任何可用的密钥。',
|
||||
isClosable: true,
|
||||
status: 'warning',
|
||||
});
|
||||
} else if (keys) {
|
||||
dispatch(kugouImportKeys(keys));
|
||||
setShowImportModal(false);
|
||||
toast({
|
||||
title: `导入完成,共导入了 ${keys.length} 个密钥。`,
|
||||
description: '记得按下「保存」来应用。',
|
||||
isClosable: true,
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: `不支持的文件:${file.name}`,
|
||||
isClosable: true,
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH={0} flexDir="column" flex={1}>
|
||||
<Heading as="h2" size="lg">
|
||||
酷狗解密密钥 (KGG / KGM v5)
|
||||
</Heading>
|
||||
|
||||
<Text>酷狗已经升级了加密方式,现在使用 KGG / KGM v5 加密。</Text>
|
||||
|
||||
<AddKey addKey={addKey} importKeyFromFile={() => setShowImportModal(true)} clearKeys={clearAll} />
|
||||
|
||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||
<List spacing={3}>
|
||||
{kugouKeys.map(({ id, audioHash, ekey }, i) => (
|
||||
<KugouEKeyItem key={id} id={id} ekey={ekey} audioHash={audioHash} i={i} />
|
||||
))}
|
||||
</List>
|
||||
{kugouKeys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportSecretModal
|
||||
clientName="酷狗音乐"
|
||||
show={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={handleSecretImport}
|
||||
>
|
||||
<KugouAllInstructions />
|
||||
</ImportSecretModal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -34,6 +34,16 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
if (settings?.kugou) {
|
||||
const { keys } = settings.kugou;
|
||||
|
||||
for (const [k, v] of enumObject(keys)) {
|
||||
if (typeof v === 'string') {
|
||||
draft.kugou.keys[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof settings?.qtfm?.android === 'string') {
|
||||
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
|
||||
}
|
||||
|
@ -2,7 +2,7 @@ import type { RootState } from '~/store';
|
||||
import { closestByLevenshtein } from '~/util/levenshtein';
|
||||
import { hasOwn } from '~/util/objects';
|
||||
import { kwm2StagingToProductionKey } from './keyFormats';
|
||||
import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
import type { ParseKugouHeaderResponse, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
|
||||
|
||||
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
|
||||
|
||||
@ -12,6 +12,9 @@ export const selectFinalQMCv2Settings = (state: RootState) => state.settings.pro
|
||||
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
|
||||
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
|
||||
|
||||
export const selectStagingKugouV5Keys = (state: RootState) => state.settings.staging.kugou.keys;
|
||||
export const selectFinalKugouV5Keys = (state: RootState) => state.settings.production.kugou.keys;
|
||||
|
||||
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
|
||||
const normalizedName = name.normalize();
|
||||
|
||||
@ -50,5 +53,16 @@ export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse):
|
||||
return ekey;
|
||||
};
|
||||
|
||||
export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse): string | undefined => {
|
||||
if (!hdr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keys = selectFinalKugouV5Keys(state);
|
||||
const lookupKey = hdr.audioHash;
|
||||
|
||||
return hasOwn(keys, lookupKey) ? keys[lookupKey] : undefined;
|
||||
};
|
||||
|
||||
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
|
||||
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
|
||||
|
@ -14,6 +14,11 @@ import {
|
||||
qmc2StagingToProductionKey,
|
||||
qmc2StagingToProductionValue,
|
||||
stagingKeyToProduction,
|
||||
ProductionKugouKey,
|
||||
kugouProductionToStaging,
|
||||
kugouStagingToProductionKey,
|
||||
kugouStagingToProductionValue,
|
||||
StagingKugouKey,
|
||||
} from './keyFormats';
|
||||
|
||||
export interface StagingSettings {
|
||||
@ -24,6 +29,9 @@ export interface StagingSettings {
|
||||
kwm2: {
|
||||
keys: StagingKWMv2Key[];
|
||||
};
|
||||
kugou: {
|
||||
keys: StagingKugouKey[];
|
||||
};
|
||||
qtfm: {
|
||||
android: string;
|
||||
};
|
||||
@ -37,6 +45,9 @@ export interface ProductionSettings {
|
||||
kwm2: {
|
||||
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
||||
};
|
||||
kugou: {
|
||||
keys: ProductionKugouKey; // { [fileName]: ekey }
|
||||
};
|
||||
qtfm: {
|
||||
android: string;
|
||||
};
|
||||
@ -47,16 +58,19 @@ export interface SettingsState {
|
||||
staging: StagingSettings;
|
||||
production: ProductionSettings;
|
||||
}
|
||||
|
||||
const initialState: SettingsState = {
|
||||
dirty: false,
|
||||
staging: {
|
||||
qmc2: { allowFuzzyNameSearch: true, keys: [] },
|
||||
kwm2: { keys: [] },
|
||||
qtfm: { android: '' },
|
||||
kugou: { keys: [] },
|
||||
},
|
||||
production: {
|
||||
qmc2: { allowFuzzyNameSearch: true, keys: {} },
|
||||
kwm2: { keys: {} },
|
||||
kugou: { keys: {} },
|
||||
qtfm: { android: '' },
|
||||
},
|
||||
};
|
||||
@ -69,6 +83,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||
kwm2: {
|
||||
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
||||
},
|
||||
kugou: {
|
||||
keys: stagingKeyToProduction(staging.kugou.keys, kugouStagingToProductionKey, kugouStagingToProductionValue),
|
||||
},
|
||||
qtfm: staging.qtfm,
|
||||
});
|
||||
|
||||
@ -80,6 +97,9 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
|
||||
kwm2: {
|
||||
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
||||
},
|
||||
kugou: {
|
||||
keys: productionKeyToStaging(production.kugou.keys, kugouProductionToStaging),
|
||||
},
|
||||
qtfm: production.qtfm,
|
||||
});
|
||||
|
||||
@ -152,14 +172,42 @@ export const settingsSlice = createSlice({
|
||||
state.dirty = true;
|
||||
}
|
||||
},
|
||||
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
|
||||
state.staging.qtfm.android = deviceKey;
|
||||
state.dirty = true;
|
||||
},
|
||||
kwm2ClearKeys(state) {
|
||||
state.staging.kwm2.keys = [];
|
||||
state.dirty = true;
|
||||
},
|
||||
kugouAddKey(state) {
|
||||
state.staging.kugou.keys.push({ id: nanoid(), audioHash: '', ekey: '' });
|
||||
state.dirty = true;
|
||||
},
|
||||
kugouImportKeys(state, { payload }: PayloadAction<Omit<StagingKugouKey, 'id'>[]>) {
|
||||
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||
state.staging.kugou.keys.push(...newItems);
|
||||
state.dirty = true;
|
||||
},
|
||||
kugouDeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
|
||||
const kugou = state.staging.kugou;
|
||||
kugou.keys = kugou.keys.filter((item) => item.id !== id);
|
||||
state.dirty = true;
|
||||
},
|
||||
kugouUpdateKey(
|
||||
state,
|
||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKugouKey; value: string }>,
|
||||
) {
|
||||
const keyItem = state.staging.kugou.keys.find((item) => item.id === id);
|
||||
if (keyItem) {
|
||||
keyItem[field] = value;
|
||||
state.dirty = true;
|
||||
}
|
||||
},
|
||||
kugouClearKeys(state) {
|
||||
state.staging.kugou.keys = [];
|
||||
state.dirty = true;
|
||||
},
|
||||
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
|
||||
state.staging.qtfm.android = deviceKey;
|
||||
state.dirty = true;
|
||||
},
|
||||
//
|
||||
discardStagingChanges: (state) => {
|
||||
state.dirty = false;
|
||||
@ -197,6 +245,12 @@ export const {
|
||||
kwm2ClearKeys,
|
||||
kwm2ImportKeys,
|
||||
|
||||
kugouAddKey,
|
||||
kugouUpdateKey,
|
||||
kugouDeleteKey,
|
||||
kugouClearKeys,
|
||||
kugouImportKeys,
|
||||
|
||||
qtfmAndroidUpdateKey,
|
||||
|
||||
commitStagingChange,
|
||||
|
@ -1,8 +1,23 @@
|
||||
import { FC, Fragment } from 'react';
|
||||
import { Center, Container, Heading, Link, ListItem, UnorderedList } from '@chakra-ui/react';
|
||||
import { Header3 } from '~/components/HelpText/Headers';
|
||||
import { KuwoFAQ } from '~/faq/KuwoFAQ';
|
||||
import { OtherFAQ } from '~/faq/OtherFAQ';
|
||||
import { QQMusicFAQ } from '~/faq/QQMusicFAQ';
|
||||
import { KugouFAQ } from '~/faq/KugouFAQ.tsx';
|
||||
|
||||
type FAQEntry = {
|
||||
id: string;
|
||||
title: string;
|
||||
Help: FC;
|
||||
};
|
||||
|
||||
const faqEntries: FAQEntry[] = [
|
||||
{ id: 'qqmusic', title: 'QQ 音乐', Help: QQMusicFAQ },
|
||||
{ id: 'kuwo', title: '酷我音乐', Help: KuwoFAQ },
|
||||
{ id: 'kugou', title: '酷狗音乐', Help: KugouFAQ },
|
||||
{ id: 'other', title: '其它问题', Help: OtherFAQ },
|
||||
];
|
||||
|
||||
export function FaqTab() {
|
||||
return (
|
||||
@ -12,22 +27,18 @@ export function FaqTab() {
|
||||
</Center>
|
||||
<Header3>答疑目录</Header3>
|
||||
<UnorderedList>
|
||||
<ListItem>
|
||||
<Link href="#faq-qqmusic">QQ 音乐</Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Link href="#faq-kuwo">酷我音乐</Link>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<Link href="#faq-other">其它问题</Link>
|
||||
</ListItem>
|
||||
{faqEntries.map(({ id, title }) => (
|
||||
<ListItem key={id}>
|
||||
<Link href={`#faq-${id}`}>{title}</Link>
|
||||
</ListItem>
|
||||
))}
|
||||
</UnorderedList>
|
||||
<Header3 id="faq-qqmusic">QQ 音乐</Header3>
|
||||
<QQMusicFAQ />
|
||||
<Header3 id="faq-kuwo">酷我音乐</Header3>
|
||||
<KuwoFAQ />
|
||||
<Header3 id="faq-other">其它问题</Header3>
|
||||
<OtherFAQ />
|
||||
{faqEntries.map(({ id, title, Help }) => (
|
||||
<Fragment key={id}>
|
||||
<Header3 id={`faq-${id}`}>{title}</Header3>
|
||||
<Help />
|
||||
</Fragment>
|
||||
))}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
@ -1,11 +1,17 @@
|
||||
import { getFileName } from './pathHelper';
|
||||
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
|
||||
import { KuGou } from '@unlock-music/crypto';
|
||||
|
||||
export interface QMAndroidKeyEntry {
|
||||
name: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export type KugouKeyEntry = {
|
||||
audioHash: string;
|
||||
ekey: string;
|
||||
};
|
||||
|
||||
export class DatabaseKeyExtractor {
|
||||
private static _instance: DatabaseKeyExtractor;
|
||||
|
||||
@ -52,4 +58,44 @@ export class DatabaseKeyExtractor {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
|
||||
extractKugouKeyFromEncryptedDb(buffer: ArrayBuffer): null | KugouKeyEntry[] {
|
||||
const dbBuffer = new Uint8Array(buffer);
|
||||
let db: SQLDatabase | null = null;
|
||||
|
||||
try {
|
||||
KuGou.decryptDatabase(dbBuffer);
|
||||
db = new this.SQL.Database(dbBuffer);
|
||||
|
||||
let sql: undefined | string;
|
||||
if (this.hasTable(db, 'ShareFileItems')) {
|
||||
sql = `
|
||||
select H, K from (
|
||||
select EncryptionKeyId as H, EncryptionKey as K from ShareFileItems
|
||||
union all
|
||||
select EnHash as H, EnKey as K from DownloadItem
|
||||
) t
|
||||
where
|
||||
t.H is not null and t.H != ''
|
||||
and t.K is not null and t.K != ''
|
||||
group by t.H
|
||||
`;
|
||||
}
|
||||
if (!sql) return null;
|
||||
|
||||
const result = db.exec(sql);
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const keys = result[0].values;
|
||||
return keys.map(([audioHash, ekey]) => ({
|
||||
// strip dir name
|
||||
audioHash: String(audioHash).normalize(),
|
||||
ekey: String(ekey).normalize(),
|
||||
}));
|
||||
} finally {
|
||||
db?.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
16
src/util/mmkv/kugou.ts
Normal file
16
src/util/mmkv/kugou.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { StagingKugouKey } from '~/features/settings/keyFormats';
|
||||
import { MMKVParser } from '../MMKVParser';
|
||||
|
||||
export function parseAndroidKugouMMKV(view: DataView): Omit<StagingKugouKey, 'id'>[] {
|
||||
const mmkv = new MMKVParser(view);
|
||||
const result: Omit<StagingKugouKey, 'id'>[] = [];
|
||||
while (!mmkv.eof) {
|
||||
const audioHash = mmkv.readString();
|
||||
const ekey = mmkv.readStringValue();
|
||||
|
||||
if (audioHash.length === 0x20 && ekey) {
|
||||
result.push({ audioHash, ekey });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import * as initSqlite from 'sql.js';
|
||||
|
||||
const urlWasm = new URL('@nm/sql.js/dist/sql-wasm.wasm', import.meta.url).toString();
|
||||
const urlWasm = new URL('@sql-wasm', import.meta.url).toString();
|
||||
|
||||
export type SQLStatic = Awaited<ReturnType<(typeof initSqlite)['default']>>;
|
||||
export type SQLDatabase = SQLStatic['Database']['prototype'];
|
||||
|
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@ -4,6 +4,5 @@ module 'virtual:pwa-register' {
|
||||
/**
|
||||
* See: {@link https://vite-pwa-org.netlify.app/guide/prompt-for-update.html}
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
declare function registerSW(opts: unknown): () => void;
|
||||
declare function registerSW(_opts: unknown): () => void;
|
||||
}
|
||||
|
@ -5,13 +5,13 @@ import url from 'node:url';
|
||||
const projectRoot = url.fileURLToPath(new URL('../', import.meta.url));
|
||||
|
||||
export function command(cmd: string, dir = '') {
|
||||
return cp.execSync(cmd, { cwd: path.join(projectRoot, dir), encoding: 'utf-8' }).trim();
|
||||
return cp.execSync(cmd, { cwd: path.resolve(projectRoot, dir), encoding: 'utf-8' }).trim();
|
||||
}
|
||||
|
||||
export function tryCommand(cmd: string, dir = '', fallback = '') {
|
||||
try {
|
||||
return command(cmd, dir);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
@ -21,7 +21,7 @@
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@nm/*": ["./node_modules/*"]
|
||||
"@sql-wasm": ["./node_modules/sql.js/dist/sql-wasm.wasm"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
|
@ -15,8 +15,8 @@ const projectRoot = url.fileURLToPath(new URL('.', import.meta.url));
|
||||
const pkg = JSON.parse(fs.readFileSync(projectRoot + '/package.json', 'utf-8'));
|
||||
|
||||
const COMMAND_GIT_VERSION = 'git describe --long --dirty --tags --always';
|
||||
const shortCommit = tryCommand(COMMAND_GIT_VERSION, __dirname, 'unknown');
|
||||
const version = `${pkg.version}-${shortCommit}`;
|
||||
const shortCommit = process.env.GIT_COMMIT || tryCommand(COMMAND_GIT_VERSION, __dirname, 'unknown');
|
||||
const version = `${pkg.version} (${shortCommit})`;
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@ -85,7 +85,7 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, 'src'),
|
||||
'@nm': path.resolve(__dirname, 'node_modules'),
|
||||
'@sql-wasm': path.resolve(__dirname, 'node_modules', 'sql.js', 'dist', 'sql-wasm.wasm'),
|
||||
|
||||
// workaround for vite, workbox (PWA)
|
||||
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
|
||||
|
Loading…
x
Reference in New Issue
Block a user