Compare commits

...

45 Commits
v0.2.7 ... main

Author SHA1 Message Date
鲁树人
0a820b620b docs: use gitea actions badge
All checks were successful
Build and Deploy / build (push) Successful in 1m56s
2025-05-08 07:22:52 +09:00
鲁树人
721d947fdb docs: add link to lib_um_crypto_rust
All checks were successful
Build and Deploy / build (push) Successful in 2m5s
2025-05-06 10:28:22 +09:00
鲁树人
1880220aaa docs: add issue template
All checks were successful
Build and Deploy / build (push) Successful in 1m58s
2025-05-04 22:13:25 +09:00
鲁树人
d91e2fffe4 chore: bump version to v0.4.7; upgrade deps
All checks were successful
Build and Deploy / build (push) Successful in 1m45s
2025-03-31 09:37:58 +09:00
鲁树人
88cfbcd337 build: retrive git hash 2025-03-31 04:03:27 +09:00
鲁树人
e9480ce6a4 chore: bump version to v0.4.2
All checks were successful
Build and Deploy / build (push) Successful in 2m9s
2025-03-28 12:15:57 +09:00
鲁树人
a07bcf2575 build: build with docker 2025-03-28 12:14:59 +09:00
鲁树人
a40ecc4569 chore: reduce vscode extensions recommended 2025-03-28 11:27:51 +09:00
鲁树人
1abfe3498f chore: bump version to v0.4.1
All checks were successful
Build and Deploy / build (push) Successful in 2m20s
2025-02-25 20:30:59 +09:00
鲁树人
e69393d1bc fix(kgm): extract hash/key from both tables 2025-02-25 20:30:25 +09:00
鲁树人
19c5d0aab9 docs: update readme
All checks were successful
Build and Deploy / build (push) Successful in 2m5s
2025-02-25 07:08:37 +09:00
鲁树人
baab3057cf 0.4.0 2025-02-25 07:08:31 +09:00
鲁树人
c71078f5da feat(kgm): kgm v5 (aka. kgg) support 2025-02-25 07:05:31 +09:00
鲁树人
acb7a634b1 docs: update readme on where to find artifact
Some checks failed
Build and Deploy / build (push) Has been cancelled
2024-12-15 21:39:52 +09:00
鲁树人
ce969af57f CI: 替换 Drone CI 为 Gitea 内建的 Actions (#80)
自动构建相关。

Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/80
Co-authored-by: 鲁树人 <lu.shuren@um-react.app>
Co-committed-by: 鲁树人 <lu.shuren@um-react.app>
2024-12-15 12:37:40 +00:00
鲁树人
ec4bd16b03 test: make vite happy 2024-12-15 04:52:36 +09:00
鲁树人
531930a6ec chore: bump version to 0.3.3 2024-12-15 04:51:12 +09:00
鲁树人
3862f2d38e fix: update packages 2024-12-15 03:57:14 +09:00
鲁树人
ddc073fbcc chore: move husky to sgh 2024-12-15 03:57:08 +09:00
鲁树人
82dbfc2d1f chore: bump node version 2024-12-15 03:57:00 +09:00
鲁树人
87d2d71193 chore: add packageManager field 2024-10-14 11:03:53 +09:00
鲁树人
759252cec5 docs: improve faq page
- add toc for faq page
- fix #79: add note about oem build
- add plat form specific instructions for qqmusic faq
2024-10-14 11:03:01 +09:00
鲁树人
afc65fd5d0 docs: added notes about mac client version requirements (close #70) 2024-10-13 05:54:12 +09:00
鲁树人
9f587212bc docs: remove todo list, add link to crypto issues 2024-10-13 05:51:31 +09:00
鲁树人
9ede00037e docs: fix link to um-crypto docs 2024-10-13 05:48:35 +09:00
鲁树人
0951963f46 docs: format with prettier 2024-10-13 05:47:07 +09:00
鲁树人
c57bc9cfbb docs: add do not fork notice 2024-10-13 05:46:38 +09:00
鲁树人
b16e3bf3ea chore: update readme 2024-09-26 21:15:03 +01:00
鲁树人
e9a95d1bd6 0.3.2 2024-09-26 21:06:00 +01:00
鲁树人
00813957d6 feat: make unsaved settings obvious 2024-09-26 21:02:42 +01:00
鲁树人
b26e62e8d9 chore: bump version to 0.3.1 2024-09-26 20:43:23 +01:00
鲁树人
9fed1ee610 fix: footer-less qmcv2 file support 2024-09-26 20:41:02 +01:00
鲁树人
5e890bca77 chore: fix husky hooks 2024-09-24 23:20:57 +01:00
鲁树人
58c96f264b Dependency upgrade + lib_um_crypto_rust (#78)
Co-authored-by: 鲁树人 <lu.shuren@um-react.app>
Co-committed-by: 鲁树人 <lu.shuren@um-react.app>
2024-09-24 22:19:30 +00:00
鲁树人
c5bc436ab2 chore: typo 2024-09-14 20:59:48 +01:00
鲁树人
486f1fe898 refactor: re-order decryption factory 2024-09-14 20:54:35 +01:00
鲁树人
8b628fd6ce refactor: slice NCM parsing 2024-09-14 20:54:10 +01:00
鲁树人
bb9529b877 fix: performance logging code 2024-09-14 20:53:44 +01:00
鲁树人
985620d188 exp: use @unlock-music/crypto backend for NCM decryption 2024-09-14 20:32:31 +01:00
鲁树人
22528481d5 ci: re-enable script to publish package 2024-09-12 23:26:18 +01:00
鲁树人
c1e17992e9 0.2.8 2024-09-12 22:59:19 +01:00
鲁树人
f478ca8818 fix: upgrade libprarkeet to v0.4.5 2024-09-12 22:56:13 +01:00
鲁树人
8e4367fbf9 build: minify final mjs 2024-01-18 00:59:11 +00:00
鲁树人
1ae2f93e99 chore: make win64 build to its own dir 2024-01-18 00:38:01 +00:00
鲁树人
741e302ea7 ci: publish site deployment to netlify as well 2024-01-18 00:29:37 +00:00
121 changed files with 8145 additions and 6835 deletions

3
.dockerignore Normal file
View File

@ -0,0 +1,3 @@
dist
node_modules
*.log

View File

@ -1,33 +0,0 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: test & build
image: node:20.10.0-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:20.10.0-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

View File

@ -11,5 +11,5 @@ charset = utf-8
trim_trailing_whitespace = 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

2
.env
View File

@ -1,4 +1,4 @@
# Example environment file for vite to use.
# For more information, see: https://vitejs.dev/guide/env-and-mode.html
ENABLE_PERF_LOG=0
VITE_ENABLE_PERF_LOG=0

View File

@ -1,3 +0,0 @@
dist/
node_modules/
coverage/

View File

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

View 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

View 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

View 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

View File

@ -0,0 +1,4 @@
---
name: '其它'
about: '如果你遇到的问题不符合上述模板的描述,请选择此项。'
---

View File

@ -0,0 +1 @@
blank_issues_enabled: false

View 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

2
.gitignore vendored
View File

@ -30,3 +30,5 @@ dist-ssr
/um-react-wry-*
/um-react*.exe
/win64/

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm exec lint-staged

View File

@ -1,4 +0,0 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm test

4
.npmrc
View File

@ -1,3 +1,3 @@
use-node-version=20.10.0
node-version=20.10.0
use-node-version=22.12.0
engine-strict=true
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/

12
.run/test.run.xml Normal file
View 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
View 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>

View File

@ -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
View 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"]

View File

@ -1,20 +1,23 @@
# Unlock Music 音乐解锁 (React)
[![Build Status](https://ci.unlock-music.dev/api/badges/um/um-react/status.svg)](https://ci.unlock-music.dev/um/um-react)
[![Build Status](https://git.unlock-music.dev/um/um-react/actions/workflows/build.yaml/badge.svg)][um-react-actions]
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
- CI 自动构建已经部署,可以在 [Packages][um-react-packages] 下载。
- CI 自动构建已经部署,可以在 [Actions][um-react-actions] 寻找对应的<ruby>构建产物<rp>(</rp><rt>Artifact</rt><rp>)</rp> </ruby>下载。
- [常见问题参考](./docs/faq_zh-hans.md)
> **WARNING**
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
[um-vue]: https://git.unlock-music.dev/um/web
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
[um-react-actions]: https://git.unlock-music.dev/um/um-react/actions?workflow=build.yaml
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
@ -29,32 +32,69 @@
- [x] 网易云音乐 (`.ncm`)
- [x] 虾米音乐 (`.xm`)
- [x] 酷我音乐 (`.kwm`)
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
- [x] 喜马拉雅 Android 端 (`.x2m` / `.x3m`)
- [x] 酷狗音乐 (`.kgm` / `.vpr` / `.kgg`)
- PC / 安卓客户端的 `kgg` 文件需要提供密钥数据库。
- [x] 喜马拉雅 (`.x2m` / `.x3m` / `.xm`)
- [x] 咪咕音乐格式 (`.mg3d`)
- [x] 蜻蜓 FM (`.qta`)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
[^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/) 等。
## 错误报告
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。
有不支持的格式?请提交样本(加密文件)与客户端信息版本信息(如系统版本、下载渠道),或一并上传其安装包到[仓库的问题追踪区][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
## 使用 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)」。
### 面向 libparakeet SDK 开发
### 解密库开发
⚠️ 如果只是进行前端方面的更改,你可以跳过该节。
请参考文档「[面向 `libparakeet-js` 开发](./docs/develop-with-libparakeet.zh.md)」。
请参考文档「[面向 `@unlock-music/crypto` 开发](./docs/develop-with-um_crypto.zh.md)」。
### 架构
@ -77,21 +117,14 @@
- [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 单文件 (需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带)
- [um-react-wry](https://git.unlock-music.dev/um/um-react-wry) - 使用 WRY 框架封装的 Win64 单文件 (
需要[安装 Edge WebView2 运行时][webview2_redist]Win10+ 操作系统自带)
- [本地下载](https://git.unlock-music.dev/um/um-react/releases/latest) | 寻找文件名为 `um-react-win64-` 开头的附件
[webview2_redist]: https://go.microsoft.com/fwlink/p/?LinkId=2124703
有新的项目提交?欢迎[提交 issue][project-issues],请带上项目名称和链接。
## TODO
- 待定
- [ ] 各类算法 [追踪 `crypto` 标签](https://git.unlock-music.dev/um/um-react/issues?labels=67)
- 完成
- [x] #7 ~~简易元数据编辑器~~ 放弃
- [x] #8 ~~添加单元测试~~ 框架加上了,以后慢慢添加更多测试即可。
- [x] #2 解密内容探测 (解密过程)
- [x] #6 文件拖放 (利用 `react-dropzone`?)

View File

@ -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 更改已合并。

View 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 更改已合并。

43
eslint.config.mjs Normal file
View 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,
},
},
},
);

View File

@ -1,11 +1,12 @@
{
"name": "um-react",
"private": true,
"version": "0.2.7",
"version": "0.4.7",
"type": "module",
"scripts": {
"start": "vite",
"build": "tsc -p tsconfig.prod.json && vite build && node scripts/write-version.mjs",
"build": "tsc -p tsconfig.prod.json && vite build && pnpm build:finalize",
"build:finalize": "node scripts/write-version.mjs && node scripts/minify-mjs.mjs",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier -w .",
"test": "vitest run",
@ -13,64 +14,76 @@
"test:coverage": "vitest run --coverage",
"preview": "vite preview",
"preview:coverage": "vite preview --outDir coverage --port 5175",
"prepare": "husky install"
"prepare": "simple-git-hooks"
},
"dependencies": {
"@chakra-ui/anatomy": "^2.2.2",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.4.3",
"@reduxjs/toolkit": "^2.0.1",
"framer-motion": "^10.16.16",
"nanoid": "^5.0.4",
"radash": "^11.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-icons": "^4.12.0",
"@chakra-ui/anatomy": "^2.3.4",
"@chakra-ui/icons": "^2.2.4",
"@chakra-ui/react": "^2.10.7",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@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": "^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.0.4",
"react-syntax-highlighter": "^15.5.0",
"sass": "^1.69.5",
"sql.js": "^1.9.0"
"react-redux": "^9.2.0",
"react-syntax-highlighter": "^15.6.1",
"sass": "^1.86.0",
"sql.js": "^1.13.0",
"workbox-build": "^7.3.0"
},
"devDependencies": {
"@rollup/plugin-replace": "^5.0.5",
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-syntax-highlighter": "^15.5.11",
"@eslint/js": "^9.23.0",
"@rollup/plugin-replace": "^6.0.2",
"@testing-library/jest-dom": "^6.6.3",
"@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": "^6.15.0",
"@typescript-eslint/parser": "^6.15.0",
"@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^1.1.0",
"@vitest/ui": "^1.1.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"husky": "^8.0.3",
"jsdom": "^23.0.1",
"lint-staged": "^15.2.0",
"prettier": "^3.1.1",
"typescript": "^5.3.3",
"vite": "^5.0.10",
"vite-plugin-pwa": "^0.17.4",
"vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.3.0",
"vitest": "^1.1.0",
"workbox-window": "^7.0.0"
"@typescript-eslint/eslint-plugin": "^8.28.0",
"@typescript-eslint/parser": "^8.28.0",
"@vitejs/plugin-react": "^4.3.4",
"@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": "^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": {
"*": "prettier --write --ignore-unknown",
"*.{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": {
"singleQuote": true,
"printWidth": 120,
@ -78,12 +91,13 @@
},
"pnpm": {
"patchedDependencies": {
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
"sql.js@1.9.0": "patches/sql.js@1.9.0.patch"
"@rollup/plugin-terser": "patches/@rollup__plugin-terser.patch",
"sql.js": "patches/sql.js.patch"
},
"overrides": {
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",
"sourcemap-codec": "npm:@jridgewell/sourcemap-codec@1.4.15"
}
}
},
"packageManager": "pnpm@9.12.1+sha512.e5a7e52a4183a02d5931057f7a0dbff9d5e9ce3161e33fa68ae392125b79282a8a8a470a51dfc8a0ed86221442eb2fb57019b0990ed24fab519bf0e1bc5ccfc4"
}

View File

@ -1,8 +1,8 @@
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
index d29af3624109025e59966cf25cb357111bb459de..1b028e3d91ec37108f775627f31f1134aec47476 100644
index b16cee5c3cbdf523f9beae920258094ae7fcbd0f..ae67be7145625c60995c5044860e87d6144a8837 100644
--- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js
@@ -190,3 +190,6 @@ else if (typeof define === 'function' && define['amd']) {
@@ -187,3 +187,6 @@ else if (typeof define === 'function' && define['amd']) {
else if (typeof exports === 'object'){
exports["Module"] = initSqlJs;
}

11846
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -96,15 +96,15 @@ deploy_netlify() {
echo " * ${error_message}"
return 1
else
echo 'Deoployed to main url.'
echo 'Deployed to main url.'
fi
fi
}
# For deployment, we care a bit less
if [[ -n "${NETLIFY_API_KEY}" && -n "${NETLIFY_SITE_ID}" ]]; then
echo "Deploy to netlify..."
deploy_netlify um-react.zip
echo "Deploy to netlify (branch: ${BRANCH_NAME})..."
deploy_netlify um-react-site.zip
else
echo "skip netlify deployment."
fi

View File

@ -1,28 +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 "$FILE" ]]; then
curl -fsL "https://um-react.app/files/${FILE}.gz" | gzip -d >"${FILE}"
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-0.1.0-linux-amd64"
dl_file "um-react-wry-stub-0.1.0-win64.exe"
chmod a+x um-react-wry-builder-0.1.0-linux-amd64
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"
./um-react-wry-builder-0.1.0-linux-amd64 \
-t um-react-wry-stub-0.1.0-win64.exe \
"./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 "${EXE_NAME}"
-o "win64/dist/${EXE_NAME}"
touch -d 1970-01-01T00:00:00Z "${EXE_NAME}"
zip -9oX "${ZIP_NAME}" -- "${EXE_NAME}"
echo "[Build OK] '${ZIP_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
View 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);
}

View File

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

View File

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

View File

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

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

View File

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

View File

@ -1,27 +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;
}
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
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,
};
}

View File

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

View 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,
];

View File

@ -1,6 +1,9 @@
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',
}

View File

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

View File

@ -1,49 +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';
import { QingTingFM$Device } from './qtfm/qtfm_device';
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,
// QingTingFM (Android)
QingTingFM$Device.make,
// Transparent crypto (not encrypted)
TransparentCrypto.make,
];

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
export const KWM_KEY = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';

View File

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

View File

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

View File

@ -1,2 +0,0 @@
export const NCM_KEY = 'hzHRAmso5kInbaxW';
export const NCM_MAGIC_HEADER = new Uint8Array([0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d]);

View File

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

View File

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

View File

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

View File

@ -1,3 +0,0 @@
export const SEED = 106;
export const ENC_V2_KEY_1 = '386ZJY!@#*$%^&)(';
export const ENC_V2_KEY_2 = '**#!(#$%&^a1cZ,T';

View File

@ -1,51 +0,0 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase';
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
import { fetchParakeet } from '@jixun/libparakeet';
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
import { makeQMCv2FooterParser, 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 = makeQMCv2FooterParser(parakeet);
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();
}
}

View File

@ -1,25 +0,0 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase';
import { DecryptCommandOptions } from '~/decrypt-worker/types';
export class QingTingFM$Device implements CryptoBase {
cryptoName = 'QingTing FM/Device ID';
checkByDecryptHeader = false;
async checkBySignature(_buffer: ArrayBuffer, options: DecryptCommandOptions) {
return Boolean(/^\.p~?!.*\.qta$/.test(options.fileName) && options.qingTingAndroidKey);
}
async decrypt(buffer: ArrayBuffer, options: DecryptCommandOptions): Promise<Blob> {
const { fileName: name, qingTingAndroidKey } = options;
if (!qingTingAndroidKey) {
throw new Error('QingTingFM Android Device Key was not provided');
}
return transformBlob(buffer, (p) => p.make.QingTingFM(name, qingTingAndroidKey));
}
public static make() {
return new QingTingFM$Device();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
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> {
let kgm: KuGou | undefined;
let kgmHdr: KuGouHeader | undefined;
try {
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)) {
kgm.decrypt(block, offset);
}
return {
status: Status.OK,
cipherName: this.cipherName,
data: audioBuffer,
};
} finally {
kgmHdr?.free();
kgm?.free();
}
}
public static make() {
return new KugouMusicDecipher();
}
}

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

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

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

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

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

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

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

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

View File

@ -2,6 +2,7 @@ export interface DecryptCommandOptions {
fileName: string;
qmc2Key?: string;
kwm2key?: string;
kugouKey?: string;
qingTingAndroidKey?: string;
}
@ -12,6 +13,32 @@ export interface DecryptCommandPayload {
}
export interface FetchMusicExNamePayload {
id: string;
blobURI: string;
}
export interface ParseKuwoHeaderPayload {
blobURI: string;
}
export type ParseKuwoHeaderResponse = null | {
resourceId: number;
qualityId: number;
};
export interface ParseKugouHeaderPayload {
blobURI: string;
}
export type ParseKugouHeaderResponse = null | {
version: number;
audioHash: string;
};
export interface GetQingTingFMDeviceKeyPayload {
product: string;
device: string;
manufacturer: string;
brand: string;
board: string;
model: string;
}

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

View File

@ -1,2 +1,13 @@
export const toArrayBuffer = async (src: Blob | ArrayBuffer) => (src instanceof Blob ? await src.arrayBuffer() : src);
export const toBlob = (src: Blob | ArrayBuffer) => (src instanceof Blob ? src : new Blob([src]));
export const toArrayBuffer = async (src: Blob | ArrayBuffer | Uint8Array<ArrayBufferLike>) =>
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];
}
}

View File

@ -1,5 +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);
export const makeQMCv2FooterParser = (p: Parakeet) => p.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);

View File

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

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

View File

@ -1,14 +1,19 @@
import { WorkerServerBus } from '~/util/WorkerEventBus';
import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
import { getUmcVersion } from '@unlock-music/crypto';
import { getSDKVersion } from '@jixun/libparakeet';
import { workerDecryptHandler } from './worker/handler/decrypt';
import { workerParseMusicExMediaName } from './worker/handler/qmcv2_parser';
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;
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, getSDKVersion);
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);

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

View File

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

View File

@ -1,30 +0,0 @@
import { fetchParakeet, FooterParserState } from '@jixun/libparakeet';
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types';
import { makeQMCv2FooterParser } from '~/decrypt-worker/util/qmc2KeyCrypto';
import { timedLogger, withGroupedLogs as withTimeGroupedLogs } from '~/util/logUtils';
export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => {
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, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()),
);
const buffer = await timedLogger(`${label}/read-src`, async () => {
// Firefox: the range header does not work...?
const blobBuffer = await blob.arrayBuffer();
if (blobBuffer.byteLength > 1024) {
return blobBuffer.slice(-1024);
}
return blobBuffer;
});
const parsed = makeQMCv2FooterParser(parakeet).parse(buffer);
if (parsed.state === FooterParserState.OK) {
return parsed.mediaName;
}
return null;
});
};

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

View File

@ -0,0 +1,17 @@
import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
import { KuwoHeader } from '@unlock-music/crypto';
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();
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;
}
};

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

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

31
src/faq/KugouFAQ.tsx Normal file
View 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>
</>
);
}

View File

@ -1,5 +1,5 @@
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Header4';
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Headers';
import { VQuote } from '~/components/HelpText/VQuote';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
import { HiWord } from '~/components/HelpText/HiWord';
@ -15,9 +15,6 @@ export function KuwoFAQ() {
<SegmentTryOfficialPlayer />
</ListItem>
<ListItem>
<Text>
<chakra.strong>2</chakra.strong>
</Text>
<Text>
<HiWord></HiWord>
<VQuote>
@ -38,10 +35,10 @@ export function KuwoFAQ() {
<Flex flexDir="column">
<Text> root </Text>
<Text>
<strong></strong>
<strong></strong>
</Text>
<Text>
<strong></strong>使使
<strong></strong>使使
</Text>
</Flex>
</Alert>

View File

@ -1,6 +1,6 @@
import { Alert, AlertIcon, Code, Container, Flex, Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { ExtLink } from '~/components/ExtLink';
import { Header4 } from '~/components/HelpText/Header4';
import { Header4 } from '~/components/HelpText/Headers';
import { VQuote } from '~/components/HelpText/VQuote';
import { ProjectIssue } from '~/components/ProjectIssue';
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
@ -63,7 +63,7 @@ export function OtherFAQ() {
<AlertIcon />
<Flex flexDir="column">
<Text>
<strong></strong>使<strong></strong>
<strong></strong>使<strong></strong>
{';使用前请自行评估风险。'}
</Text>
</Flex>

View File

@ -1,63 +1,159 @@
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, UnorderedList, chakra } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Header4';
import { Accordion, AccordionButton, AccordionIcon, AccordionItem, AccordionPanel, Box } from '@chakra-ui/react';
import { Alert, AlertIcon, Container, Flex, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Headers';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
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() {
return (
<>
<Header4></Header4>
<List spacing={2}>
<ListItem>
<SegmentTryOfficialPlayer />
</ListItem>
<ListItem>
<Text>
<chakra.strong>2</chakra.strong>
</Text>
<Text>
Windows 19.43
QQ Windows v19.43
</Text>
<UnorderedList pl={3}>
<ListItem>
<Text>
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
<code>qq.com</code>
</ExtLink>
</Text>
</ListItem>
<ListItem>
<Text>
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
<code>Archive.org</code>
</ExtLink>
</Text>
</ListItem>
</UnorderedList>
<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>
<Text>
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1951.exe">
<code>Archive.org</code>
</ExtLink>
</Text>
</ListItem>
</UnorderedList>
</AccordionPanel>
</AccordionItem>
<Container p={2}>
<Alert status="warning" borderRadius={5}>
<AlertIcon />
<Flex flexDir="column">
<Text>iOS </Text>
<Text>root</Text>
</Flex>
</Alert>
</Container>
<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>
<Container p={2} pt={0}>
<Alert status="info" borderRadius={5}>
<AlertIcon />
</Alert>
</Container>
<SegmentKeyImportInstructions
tab="QMCv2 密钥"
keyInstructionText="查看密钥提取说明:"
clientInstructions={
<Box p={2}>
<InstructionsMac />
</Box>
}
/>
</AccordionPanel>
</AccordionItem>
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2QQMusicAllInstructions />} />
</ListItem>
</List>
<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}>
<Alert status="warning" borderRadius={5}>
<AlertIcon />
<Flex flexDir="column">
<Text>iOS </Text>
</Flex>
</Alert>
</Container>
<SegmentKeyImportInstructions
tab="QMCv2 密钥"
keyInstructionText="查看密钥提取说明:"
clientInstructions={
<Box p={2}>
<InstructionsIOS />
</Box>
}
/>
</AccordionPanel>
</AccordionItem>
</Accordion>
</>
);
}

View File

@ -8,9 +8,14 @@ import { MdFileUpload } from 'react-icons/md';
export interface SegmentKeyImportInstructionsProps {
clientInstructions: React.ReactNode;
tab: string;
keyInstructionText?: React.ReactNode;
}
export function SegmentKeyImportInstructions({ clientInstructions, tab }: SegmentKeyImportInstructionsProps) {
export function SegmentKeyImportInstructions({
clientInstructions,
tab,
keyInstructionText = '选择你的客户端平台来查看密钥提取说明:',
}: SegmentKeyImportInstructionsProps) {
return (
<>
<Text></Text>
@ -33,7 +38,7 @@ export function SegmentKeyImportInstructions({ clientInstructions, tab }: Segmen
</Flex>
</ListItem>
<ListItem>
<Text></Text>
<Text>{keyInstructionText}</Text>
<Tabs display="flex" flexDir="column" border="1px solid" borderColor="gray.300" borderRadius={5}>
{clientInstructions}
</Tabs>

View File

@ -1,12 +1,12 @@
import { Text, chakra } from '@chakra-ui/react';
import { Alert, AlertIcon, Container } from '@chakra-ui/react';
export function SegmentTryOfficialPlayer() {
return (
<>
<Text>
<chakra.strong>1</chakra.strong>
</Text>
<Text></Text>
</>
<Container p={2} my={2} pt={0}>
<Alert status="info" borderRadius={5}>
<AlertIcon />
</Alert>
</Container>
);
}

View File

@ -1,4 +1,4 @@
import { chakra, Box, Button, Collapse, Text, useDisclosure } from '@chakra-ui/react';
import { Box, Button, chakra, Collapse, Text, useDisclosure } from '@chakra-ui/react';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
export interface FileErrorProps {
@ -18,11 +18,12 @@ export function FileError({ error, code }: FileErrorProps) {
<Box>
<Text>
<chakra.span>
<chakra.span color="red.700">{errorSummary}</chakra.span>
<chakra.span color="red.700">{errorSummary}</chakra.span>
</chakra.span>
{error && (
<Button ml="2" onClick={onToggle} type="button">
</Button>
)}
</Text>

View File

@ -1,12 +1,22 @@
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import type { RootState } from '~/store';
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions, FetchMusicExNamePayload } from '~/decrypt-worker/types';
import type {
DecryptCommandOptions,
FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse,
ParseKuwoHeaderPayload,
ParseKuwoHeaderResponse
} from '~/decrypt-worker/types';
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
import {
selectKugouKey,
selectKWMv2Key,
selectQMCv2KeyByFileName,
selectQtfmAndroidKey
} from '../settings/settingsSelector';
export enum ProcessState {
QUEUED = 'QUEUED',
@ -43,6 +53,7 @@ export interface FileListingState {
files: Record<string, DecryptedAudioFile>;
displayMode: ListingMode;
}
const initialState: FileListingState = {
files: {},
displayMode: ListingMode.LIST,
@ -64,28 +75,25 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
};
const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } })
.then((r) => r.blob())
.then((r) => r.arrayBuffer())
.then((r) => {
if (r.byteLength > 1024) {
return r.slice(0, 1024);
}
return r;
});
const qmcv2MusicExMediaFile = await workerClientBus.request<string, FetchMusicExNamePayload>(
DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME,
{
id: fileId,
const [qmcv2MusicExMediaFile, kuwoHdr, kugouHdr] = await Promise.all([
workerClientBus.request<string, FetchMusicExNamePayload>(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
blobURI: file.raw,
},
);
}),
workerClientBus.request<ParseKuwoHeaderResponse, ParseKuwoHeaderPayload>(
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
{ blobURI: file.raw },
),
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, new DataView(fileHeader)),
kwm2key: selectKWMv2Key(state, kuwoHdr),
kugouKey: selectKugouKey(state, kugouHdr),
qingTingAndroidKey: selectQtfmAndroidKey(state),
};
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);

View File

@ -1,8 +1,8 @@
import {
chakra,
Box,
Button,
Center,
chakra,
Flex,
HStack,
Icon,
@ -19,22 +19,24 @@ import {
TabPanels,
Tabs,
Text,
VStack,
useBreakpointValue,
useToast,
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: '其它/待定',
@ -145,7 +147,7 @@ export function Settings() {
onClick={handleResetSettings}
colorScheme="red"
variant="ghost"
title="放弃未储存的更改,将设定还原储存前的状态。"
title="放弃未储存的更改,将设定还原储存前的状态。"
aria-label="放弃未储存的更改"
/>
<Button onClick={handleApplySettings}></Button>

View File

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

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

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

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

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

View File

@ -4,8 +4,8 @@ import {
ButtonGroup,
Checkbox,
Flex,
HStack,
Heading,
HStack,
Icon,
IconButton,
List,
@ -61,7 +61,7 @@ export function PanelQMCv2Key() {
alert(`不是支持的 SQLite 数据库文件。`);
return;
}
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1/i.test(file.name)) {
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1|(\.mmkv$)/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer();
const map = parseAndroidQmEKey(new DataView(fileBuffer));
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));

View File

@ -13,12 +13,14 @@ import {
} from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from '~/hooks';
import { fetchParakeet } from '@jixun/libparakeet';
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';
@ -38,31 +40,23 @@ export function PanelQingTing() {
return;
}
const dataMap = new Map();
for (const [_unused, key, value] of plainText.matchAll(
/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim,
)) {
dataMap.set(key.toLowerCase(), value);
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;
const product = dataMap.get('product') ?? null;
const device = dataMap.get('device') ?? null;
const manufacturer = dataMap.get('manufacturer') ?? null;
const brand = dataMap.get('brand') ?? null;
const board = dataMap.get('board') ?? null;
const model = dataMap.get('model') ?? null;
if (
product !== null &&
device !== null &&
manufacturer !== null &&
brand !== null &&
board !== null &&
model !== null
) {
if (product && device && manufacturer && brand && board && model) {
e.preventDefault();
fetchParakeet().then((parakeet) => {
setSecretKey(parakeet.qtfm.createDeviceKey(product, device, manufacturer, brand, board, model));
});
workerClientBus
.request<string, GetQingTingFMDeviceKeyPayload>(
DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY,
dataMap,
)
.then(setSecretKey)
.catch((err) => {
alert(`生成设备密钥时发生错误: ${err}`);
});
}
};

Some files were not shown because too many files have changed in this diff Show More