45 Commits

Author SHA1 Message Date
鲁树人
2d50a45ef2 0.2.4 2023-12-24 12:16:08 +01:00
鲁树人
fcc4b14211 feat: support for qmcv2 musicex tail 2023-12-24 12:15:56 +01:00
鲁树人
6c21150fc8 0.2.3 2023-12-23 19:39:46 +00:00
鲁树人
e98470cb70 Merge branch 'docs/android-emu-root' 2023-12-23 19:38:45 +00:00
鲁树人
bb8f69f137 Merge pull request '安卓 root 相关说明' (#63) from docs/android-emu-root into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/63
2023-12-23 16:04:50 +00:00
鲁树人
f194dfd135 build: cache webp images 2023-12-23 15:59:28 +00:00
鲁树人
7e741412a8 docs: typo 2023-12-23 15:54:22 +00:00
鲁树人
fe39ac6604 docs: re-order faq sections 2023-12-23 15:48:51 +00:00
鲁树人
d781767dd0 docs: update in-app-faq about broken android browsers 2023-12-23 15:47:35 +00:00
鲁树人
a7158a75e9 docs: update list of issues with broken android browsers 2023-12-23 15:45:02 +00:00
鲁树人
2bd35f899d docs: format supported format, add qtfm, added warn about android browser 2023-12-23 15:44:45 +00:00
鲁树人
bb37da5066 chore: bump node to v20.10.0 2023-12-23 15:41:13 +00:00
鲁树人
27a91a67bb docs: document broken browsers 2023-12-23 11:20:31 +00:00
鲁树人
067ad6e40b docs: update offline faq with android emu root notes #62 2023-12-23 11:15:32 +00:00
鲁树人
c95bcd7eda docs: update offline md faq with updated notes 2023-12-23 11:13:23 +00:00
鲁树人
caed717755 add notes about android emu for root #62 2023-12-23 11:11:38 +00:00
鲁树人
16bb7cb0fb docs: update faq 2023-12-22 11:02:56 +00:00
鲁树人
24fc661953 0.2.2 2023-12-22 10:48:18 +00:00
鲁树人
6b3ce9b031 ci: disable publish to gitea as it is still broken 2023-12-22 10:39:08 +00:00
鲁树人
b2528969a0 Merge pull request '#58 蜻蜓FM 安卓端支援' (#59) from feat/qingting-fm into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/59
2023-12-22 10:35:17 +00:00
鲁树人
c89801a950 ci: enable publish to gitea 2023-12-22 10:34:47 +00:00
鲁树人
314e87f448 Merge remote-tracking branch 'origin/main' into feat/qingting-fm 2023-12-22 10:34:12 +00:00
鲁树人
935d6a776a feat: qtfm instructions & notes 2023-12-22 10:33:12 +00:00
鲁树人
6f733864b7 fix: mono font styling 2023-12-22 10:32:26 +00:00
鲁树人
e8d1dba0e2 chore: typo 2023-12-21 22:57:59 +00:00
鲁树人
9f1c8877db build: remove workaround for test 2023-12-21 22:51:44 +00:00
鲁树人
889a6cbcd0 build: fix build issue with vite/vite-pwa/parakeet; upgrade deps 2023-12-21 22:49:12 +00:00
鲁树人
83b06dbe60 feat: add basic instruction to paste device key from qtfm-device-id 2023-12-21 19:34:37 +00:00
鲁树人
e2b7a753dc ci: don't publish to gitea as it is broken 2023-11-29 23:53:46 +00:00
鲁树人
4498ab6592 Merge remote-tracking branch 'origin/main' into feat/qingting-fm 2023-11-29 23:49:59 +00:00
鲁树人
da853ba6e0 Merge pull request '酷我 iOS 数据库支持' (#60) from feat/kwm-ios-support into main
Reviewed-on: https://git.unlock-music.dev/um/um-react/pulls/60
2023-11-29 23:49:43 +00:00
鲁树人
18d02a906b feat: initial implementation of qtfm android 2023-11-29 23:45:56 +00:00
鲁树人
06f257d3bb fix: fix bad refactor 2023-11-08 20:49:13 +00:00
鲁树人
ecc34aaf44 feat: add support for kuwo ios ekey db 2023-11-08 20:40:41 +00:00
鲁树人
85ab69d41d docs: readme for supported version 2023-11-03 00:07:49 +00:00
鲁树人
fbf7749e55 Merge commit 'e678e40b86a398a095cec88283c86aa3e8e2f096' 2023-11-02 23:58:33 +00:00
鲁树人
a0bab29966 docs: update faq to include valid version of qmpc #52 2023-11-02 23:57:54 +00:00
鲁树人
e678e40b86 feat: experimental support for douban key import 2023-10-19 02:58:50 +01:00
鲁树人
c39d2edce7 test: fix type for test 2023-10-19 02:39:30 +01:00
鲁树人
31420ac515 chore: text update for faq 2023-10-19 02:16:21 +01:00
鲁树人
82f15e204c chore: readme update 2023-10-19 02:16:12 +01:00
鲁树人
a1044b0c3a chore: prefer node v20.8.1 over v18 2023-10-19 02:05:23 +01:00
鲁树人
bf7fc908bc chore: update readme with link to old project 2023-10-11 23:12:00 +01:00
鲁树人
4905511d71 chore: fix pwa build 2023-10-11 23:08:27 +01:00
鲁树人
022c9d1eac chore: pwa update prompt 2023-10-11 23:06:09 +01:00
62 changed files with 2494 additions and 1809 deletions

View File

@@ -5,7 +5,7 @@ name: default
steps:
- name: test & build
image: node:18.16.1-bookworm
image: node:20.10.0-bookworm
commands:
# - git config --global --add safe.directory "/drone/src"
- corepack enable
@@ -17,7 +17,7 @@ steps:
npm_config_registry: https://registry.npmmirror.com
- name: publish
image: node:18.16.1-bookworm
image: node:20.10.0-bookworm
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
@@ -29,5 +29,5 @@ steps:
commands:
# - git config --global --add safe.directory "/drone/src"
- python3 -m zipfile -c um-react.zip dist/.
- ./scripts/publish.sh
# - ./scripts/publish.sh
- ./scripts/deploy.sh

4
.npmrc
View File

@@ -1,3 +1,3 @@
use-node-version=18.16.0
node-version=18.16.0
use-node-version=20.10.0
node-version=20.10.0
engine-strict=true

View File

@@ -3,6 +3,7 @@
[![Build Status](https://ci.unlock-music.dev/api/badges/um/um-react/status.svg)](https://ci.unlock-music.dev/um/um-react)
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
@@ -10,21 +11,34 @@
- [常见问题参考](./docs/faq_zh-hans.md)
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
[um-vue]: https://git.unlock-music.dev/um/web
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
## 支持的格式
- [x] QQ 音乐 QMCv1 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
- [x] QQ 音乐 QMCv2 PC 端 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
- [x] 网易云音乐 (.ncm)
- [x] 虾米音乐 (.xm)
- [x] 酷我音乐 (.kwm)
- [x] 酷狗音乐 (.kgm/.vpr)
- [x] 喜马拉雅 Android 端 (.x2m/.x3m)
- [x] 咪咕音乐格式 (.mg3d)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~
- [x] QQ 音乐 QMCv1 (`.qmc3` / `.qmcflac` 等)
- [x] QQ 音乐 QMCv2
- PC 客户端 (`.mflac` / `.mgg` 等) [^qm-key-pc]
- 安卓客户端 (`.mflac0` / `.mgg1` / `.mggl` 等) [^qm-key-android]
- iOS 客户端 (`.mgalaxy` 等) [^qm-key-ios]
- Mac 客户端 (`.mflach` 等) [^qm-key-mac]
- [x] 网易云音乐 (`.ncm`)
- [x] 虾米音乐 (`.xm`)
- [x] 酷我音乐 (`.kwm`)
- [x] 酷狗音乐 (`.kgm` / `.vpr`)
- [x] 喜马拉雅 Android 端 (`.x2m` / `.x3m`)
- [x] 咪咕音乐格式 (`.mg3d`)
- [x] 蜻蜓 FM (`.qta`)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (`.ofl_en`)~~
[^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/) 等。

View File

@@ -10,7 +10,7 @@
#### 2、检查您的平台。
日前,<mark>仅 Windows 客户端</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
日前,<mark>仅 Windows 客户端 v19.43 或以下版本</mark>下载的歌曲无需密钥,其余平台的官方正式版本均需要提取密钥。
> iOS 用户提取歌曲困难建议换用电脑操作Android 用户提取密钥需要 root也建议用电脑操作。
@@ -34,6 +34,8 @@
日前,<mark>仅手机客户端</mark>下载的歌曲**至臻全景声**及**至臻母带**为新版加密手机平台的其他音质暂时不需要提取密钥PC 平台暂未推出使用新版加密的音质。
※ 已知部分第三方修改版会破坏密钥写出功能,导致无法导入密钥。请使用官方版本。
> Android 用户提取密钥需要 root或者注入文件提供器。
提取密钥教程请访问[新版解锁网站](https://um-react.netlify.app/),前往网站内的设置 →<mark>切换密钥为 KWMv2 密钥</mark>→“添加一条密钥”旁的<mark>**下拉按钮**</mark>→ 从文件导入密钥…→ 选择您对应的平台查看具体教程。
@@ -54,6 +56,35 @@
目前新版没有做歌曲信息匹配与编辑,所以歌曲如果自己没有写入歌曲信息,解出来就是没有的。
### 安卓 root 相关
对安卓设备获取 root 特权通常会破坏系统的完整性并导致部分功能无法使用。
例如部分厂商的安卓设备会在解锁后丧失保修资格,或导致无法使用 NFC 移动支付功能等限制。
如果希望不破坏系统完整性,你可以考虑使用模拟器。
目前常见的带有 root 特权支持的的安卓模拟器方案,分别是雷电模拟器(※ 官方版有内置广告)和微软在 Windows 11 开始支援的适用于 Android™ 的 Windows 子系统 (WSA)。
- WSA 可以参考 [MagiskOnWSALocal](https://github.com/LSPosed/MagiskOnWSALocal) 的说明操作。
- 雷电模拟器可以在「模拟器设置」 → 「其他设置」中启用 root 特权。
![雷电模拟器 其他设置](../src/faq/assets/ld_settings_misc.webp)
### Via 等浏览器无法正常解密/下载
⚠️ 手机端浏览器支持有限,请使用最新版本的 Chrome 或 Firefox 官方浏览器。
已知有问题的浏览器:
- Via 浏览器
- 夸克浏览器
- UC 浏览器
可能会遇到的问题包括:
- 网页白屏
- 无法下载解密后内容
- 下载的文件名错误
### 新版解锁网站没有批量下载
目前没有做。抱歉。

View File

@@ -1,7 +1,7 @@
{
"name": "um-react",
"private": true,
"version": "0.2.1",
"version": "0.2.4",
"type": "module",
"scripts": {
"start": "vite",
@@ -16,56 +16,56 @@
"prepare": "husky install"
},
"dependencies": {
"@chakra-ui/anatomy": "^2.2.1",
"@chakra-ui/anatomy": "^2.2.2",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.3.0",
"@reduxjs/toolkit": "^1.9.7",
"framer-motion": "^10.16.4",
"immer": "^10.0.3",
"nanoid": "^5.0.1",
"@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.11.0",
"react-icons": "^4.12.0",
"react-promise-suspense": "^0.3.4",
"react-redux": "^8.1.3",
"react-redux": "^9.0.4",
"react-syntax-highlighter": "^15.5.0",
"sass": "^1.69.2",
"sql.js": "^1.8.0"
"sass": "^1.69.5",
"sql.js": "^1.9.0"
},
"devDependencies": {
"@rollup/plugin-replace": "^5.0.3",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@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.8.4",
"@types/react": "^18.2.28",
"@types/react-dom": "^18.2.13",
"@types/react-syntax-highlighter": "^15.5.8",
"@types/sql.js": "^1.4.5",
"@typescript-eslint/eslint-plugin": "^6.7.5",
"@typescript-eslint/parser": "^6.7.5",
"@vitejs/plugin-react": "^4.1.0",
"@vitest/coverage-v8": "^0.34.6",
"@vitest/ui": "^0.34.6",
"eslint": "^8.51.0",
"eslint-config-prettier": "^9.0.0",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-syntax-highlighter": "^15.5.11",
"@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.3",
"eslint-plugin-react-refresh": "^0.4.5",
"husky": "^8.0.3",
"jsdom": "^22.1.0",
"lint-staged": "^14.0.1",
"prettier": "^3.0.3",
"typescript": "^5.2.2",
"vite": "^4.4.11",
"vite-plugin-pwa": "^0.16.5",
"vite-plugin-top-level-await": "^1.3.1",
"vite-plugin-wasm": "^3.2.2",
"vitest": "^0.34.6"
"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"
},
"lint-staged": {
"*": "prettier --write --ignore-unknown",
@@ -79,7 +79,7 @@
"pnpm": {
"patchedDependencies": {
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch",
"sql.js@1.8.0": "patches/sql.js@1.8.0.patch"
"sql.js@1.9.0": "patches/sql.js@1.9.0.patch"
},
"overrides": {
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3",

View File

@@ -1,12 +1,11 @@
diff --git a/dist/sql-wasm.js b/dist/sql-wasm.js
index e0da60ba096433d9af1c7025d2ffb9c521f190ed..89a5da6af23e1a644106d38dafe7cfa85500a8c4 100644
index d29af3624109025e59966cf25cb357111bb459de..1b028e3d91ec37108f775627f31f1134aec47476 100644
--- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) {
@@ -190,3 +190,6 @@ else if (typeof define === 'function' && define['amd']) {
else if (typeof exports === 'object'){
exports["Module"] = initSqlJs;
}
+
+var module;
+export default initSqlJs;
+

3262
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,6 @@ import {
Box,
Code,
Heading,
Link,
ListItem,
OrderedList,
Text,
@@ -19,6 +18,7 @@ import hljsStyleGitHub from 'react-syntax-highlighter/dist/esm/styles/hljs/githu
import PowerShellAdbDumpCommandTemplate from './adb_dump.ps1?raw';
import ShellAdbDumpCommandTemplate from './adb_dump.sh?raw';
import { ExtLink } from '../ExtLink';
const applyTemplate = (tpl: string, values: Record<string, unknown>) => {
return tpl.replace(/\{\{\s*(\w+)\s*\}\}/g, (_, key) => (Object.hasOwn(values, key) ? String(values[key]) : '<nil>'));
@@ -36,10 +36,19 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
return (
<>
<Text>
<code>root</code> 访访
<ruby>
<rp> (</rp>
<rt>
<code>root</code>
</rt>
<rp>)</rp>
</ruby>
访访
</Text>
<Text>
<code>root</code>
<chakra.span color="red.400"></chakra.span>
</Text>
@@ -92,13 +101,13 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
<OrderedList>
<ListItem>
<Text>
<code>adb</code>
<Code>adb</Code>
</Text>
<Text>
💡
<Link href="https://scoop.sh/#/apps?q=adb" isExternal>
<ExtLink href="https://scoop.sh/#/apps?q=adb">
使 Scoop <ExternalLinkIcon />
</Link>
</ExtLink>
</Text>
</ListItem>
@@ -134,6 +143,11 @@ export function AndroidADBPullInstruction({ dir, file }: AndroidADBPullInstructi
</Heading>
<AccordionPanel pb={4}>
<OrderedList>
<ListItem>
<Text>
<Code>adb</Code>
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>

View File

@@ -33,7 +33,7 @@ export function AppRoot() {
</Tab>
<Tab>
<Icon as={MdQuestionAnswer} mr="1" />
<chakra.span></chakra.span>
<chakra.span></chakra.span>
</Tab>
</TabList>

View File

@@ -0,0 +1,12 @@
import type { AnchorHTMLAttributes } from 'react';
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { Link } from '@chakra-ui/react';
export function ExtLink({ children, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) {
return (
<Link isExternal {...props} rel="noreferrer noopener nofollow">
{children}
<ExternalLinkIcon />
</Link>
);
}

View File

@@ -35,7 +35,9 @@ export function ImportSecretModal({ clientName, children, show, onClose, onImpor
<FileInput onReceiveFiles={handleFileReceived}></FileInput>
</Center>
<Text mt={2}>{clientName && <>{clientName}</>}</Text>
<Text as="div" mt={2}>
{clientName && <>{clientName}</>}
</Text>
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
{children}
</Flex>

View File

@@ -7,9 +7,11 @@ export interface KuwoHeader {
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 (bytesToUTF8String(magic) !== 'yeelion-kuwo-tme') {
if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
return null; // not kuwo-encrypted file
}

View File

@@ -1,5 +1,6 @@
export enum DECRYPTION_WORKER_ACTION_NAME {
DECRYPT = 'DECRYPT',
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
VERSION = 'VERSION',
}

View File

@@ -9,6 +9,7 @@ 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)
@@ -40,6 +41,9 @@ export const allCryptoFactories: CryptoFactory[] = [
XimalayaAndroidCrypto.makeX2M,
XimalayaAndroidCrypto.makeX3M,
// QingTingFM (Android)
QingTingFM$Device.make,
// Transparent crypto (not encrypted)
TransparentCrypto.make,
];

View File

@@ -1,10 +1,9 @@
import { transformBlob } from '~/decrypt-worker/util/transformBlob';
import type { CryptoBase } from '../CryptoBase';
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
import { SEED, ENC_V2_KEY_1, ENC_V2_KEY_2 } from './qmc_v2.key.ts';
import { fetchParakeet } from '@jixun/libparakeet';
import { stringToUTF8Bytes } from '~/decrypt-worker/util/utf8Encoder.ts';
import { makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
import { makeQMCv2FooterParser, makeQMCv2KeyCrypto } from '~/decrypt-worker/util/qmc2KeyCrypto.ts';
export class QMC2Crypto implements CryptoBase {
cryptoName = 'QMC/v2';
@@ -12,7 +11,7 @@ export class QMC2Crypto implements CryptoBase {
async decrypt(buffer: ArrayBuffer): Promise<Blob> {
const parakeet = await fetchParakeet();
const footerParser = parakeet.make.QMCv2FooterParser(SEED, ENC_V2_KEY_1, ENC_V2_KEY_2);
const footerParser = makeQMCv2FooterParser(parakeet);
return transformBlob(buffer, (p) => p.make.QMCv2(footerParser), {
parakeet,
cleanup: () => footerParser.delete(),

View File

@@ -0,0 +1,25 @@
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,6 +1,8 @@
export interface DecryptCommandOptions {
fileName: string;
qmc2Key?: string;
kwm2key?: string;
qingTingAndroidKey?: string;
}
export interface DecryptCommandPayload {
@@ -8,3 +10,8 @@ export interface DecryptCommandPayload {
blobURI: string;
options: DecryptCommandOptions;
}
export interface FetchMusicExNamePayload {
id: string;
blobURI: string;
}

View File

@@ -2,3 +2,4 @@ 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

@@ -4,9 +4,11 @@ import { DECRYPTION_WORKER_ACTION_NAME } from './constants';
import { getSDKVersion } from '@jixun/libparakeet';
import { workerDecryptHandler } from './worker/handler/decrypt';
import { workerParseMusicExMediaName } from './worker/handler/qmcv2_parser';
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);

View File

@@ -0,0 +1,27 @@
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 '# N/A';
});
};

View File

@@ -1,2 +1,5 @@
// This is a dummy module for vite/rollup to resolve.
Object.defineProperty(Object.create(null), { sideEffects: true });
export function createRequire() {
import('radash'); // we need to import something, so vite don't complain on build
throw new Error('this is a dummy module. Do not use');
}

View File

@@ -37,7 +37,12 @@ export function KuwoFAQ() {
<AlertIcon />
<Flex flexDir="column">
<Text> root </Text>
<Text>使使</Text>
<Text>
<strong></strong>
</Text>
<Text>
<strong></strong>使使
</Text>
</Flex>
</Alert>
</Container>

View File

@@ -1,7 +1,9 @@
import { ExternalLinkIcon } from '@chakra-ui/icons';
import { Link, Text } from '@chakra-ui/react';
import { Img, ListItem, Text, UnorderedList } from '@chakra-ui/react';
import { ExtLink } from '~/components/ExtLink';
import { Header4 } from '~/components/HelpText/Header4';
import { VQuote } from '~/components/HelpText/VQuote';
import { ProjectIssue } from '~/components/ProjectIssue';
import LdPlayerSettingsScreen from './assets/ld_settings_misc.webp';
export function OtherFAQ() {
return (
@@ -9,18 +11,72 @@ export function OtherFAQ() {
<Header4></Header4>
<Text></Text>
<Text>使</Text>
<Header4></Header4>
<Header4></Header4>
<Text>
<ProjectIssue id={34} title="[UI] 全部下载功能" /> {' '}
<ProjectIssue id={43} title="批量下载" />
{'暂时没有实现,不过你可以在 '}
<ProjectIssue id={34} title="[UI] 全部下载功能" />
{' 以及 '}
<ProjectIssue id={43} title="批量下载" />
{' 追踪该问题。'}
</Text>
<Header4>安卓: 浏览器支持说明</Header4>
<Text> 使 Chrome Firefox </Text>
<Text></Text>
<UnorderedList>
<ListItem>Via </ListItem>
<ListItem></ListItem>
<ListItem>UC </ListItem>
</UnorderedList>
<Text></Text>
<UnorderedList>
<ListItem></ListItem>
<ListItem></ListItem>
<ListItem></ListItem>
</UnorderedList>
<Header4>安卓: root </Header4>
<Text>
root 使
使 NFC
</Text>
<Text>使</Text>
<Text>
root 广 Windows 11
<ExtLink href="https://learn.microsoft.com/zh-cn/windows/android/wsa/">
<ruby>
Android Windows (WSA)
<rp> (</rp>
<rt>
<code>Windows Subsystem for Android</code>
</rt>
<rp>)</rp>
</ruby>
</ExtLink>
</Text>
<UnorderedList>
<ListItem>
<Text>
{'WSA 可以参考 '}
<ExtLink href="https://github.com/LSPosed/MagiskOnWSALocal">MagiskOnWSALocal</ExtLink>
{' 的说明操作。'}
</Text>
</ListItem>
<ListItem>
<Text>
<VQuote></VQuote> <VQuote></VQuote> root
</Text>
<Img borderRadius={5} border="1px solid #ccc" src={LdPlayerSettingsScreen}></Img>
</ListItem>
</UnorderedList>
<Header4></Header4>
<Text>
{'欢迎进入 '}
<Link href={'https://t.me/unlock_music_chat'} isExternal>
Telegram -
<ExternalLinkIcon />
</Link>
<ExtLink href={'https://t.me/unlock_music_chat'}>Telegram - </ExtLink>
{' 一起探讨。'}
</Text>
</>

View File

@@ -1,8 +1,9 @@
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, chakra } from '@chakra-ui/react';
import { Alert, AlertIcon, Container, Flex, List, ListItem, Text, UnorderedList, chakra } from '@chakra-ui/react';
import { Header4 } from '~/components/HelpText/Header4';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer';
import { QMCv2AllInstructions } from '~/features/settings/panels/QMCv2/QMCv2AllInstructions';
import { QMCv2QQMusicAllInstructions } from '~/features/settings/panels/QMCv2/QMCv2QQMusicAllInstructions';
import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
import { ExtLink } from '~/components/ExtLink';
export function QQMusicFAQ() {
return (
@@ -16,7 +17,26 @@ export function QQMusicFAQ() {
<Text>
<chakra.strong>2</chakra.strong>
</Text>
<Text>Windows客户端下载的歌曲无需密钥</Text>
<Text>
Windows 19.43
QQ Windows v19.43
</Text>
<UnorderedList pl={3}>
<ListItem>
<Text>
<ExtLink href="https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
<code>qq.com</code>
</ExtLink>
</Text>
</ListItem>
<ListItem>
<Text>
<ExtLink href="https://web.archive.org/web/2023/https://dldir1v6.qq.com/music/clntupate/QQMusic_Setup_1943.exe">
<code>Archive.org</code>
</ExtLink>
</Text>
</ListItem>
</UnorderedList>
<Container p={2}>
<Alert status="warning" borderRadius={5}>
@@ -35,7 +55,7 @@ export function QQMusicFAQ() {
</Alert>
</Container>
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2AllInstructions />} />
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2QQMusicAllInstructions />} />
</ListItem>
</List>
</>

View File

@@ -16,9 +16,8 @@ export function SegmentAddKeyDropdown() {
ml="2"
borderTopLeftRadius={0}
borderBottomLeftRadius={0}
isDisabled
css={{ ':disabled': { opacity: 1 } }}
aria-label="示例按钮"
pointerEvents="none"
aria-label="下拉按钮"
/>
</Flex>
);

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -92,7 +92,7 @@ export function FileRow({ id, file }: FileRowProps) {
</WrapItem>
<WrapItem>
{file.decrypted && (
<Link isExternal href={file.decrypted} download={decryptedName}>
<Link href={file.decrypted} download={decryptedName}>
<Button as="span"></Button>
</Link>
)}

View File

@@ -2,11 +2,11 @@ import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '~/store';
import type { DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import { decryptionQueue } from '~/decrypt-worker/client';
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions, FetchMusicExNamePayload } from '~/decrypt-worker/types';
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector';
import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
export enum ProcessState {
QUEUED = 'QUEUED',
@@ -44,7 +44,7 @@ export interface FileListingState {
displayMode: ListingMode;
}
const initialState: FileListingState = {
files: Object.create(null),
files: {},
displayMode: ListingMode.LIST,
};
@@ -64,17 +64,29 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
};
const fileHeader = await fetch(file.raw, {
headers: {
Range: 'bytes=0-1023',
},
})
const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } })
.then((r) => r.blob())
.then((r) => r.arrayBuffer());
.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,
blobURI: file.raw,
},
);
const options: DecryptCommandOptions = {
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
fileName: file.fileName,
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
qingTingAndroidKey: selectQtfmAndroidKey(state),
};
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
});

View File

@@ -30,10 +30,12 @@ import { useAppDispatch, useAppSelector } from '~/hooks';
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
import { selectIsSettingsNotSaved } from './settingsSelector';
import { PanelQingTing } from './panels/PanelQingTing';
const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
{ name: '蜻蜓 FM', Tab: PanelQingTing },
{
name: '其它/待定',
Tab: () => <Text></Text>,

View File

@@ -3,7 +3,7 @@ import { objectify } from 'radash';
export function productionKeyToStaging<S, P extends Record<string, unknown>>(
src: P,
make: (k: keyof P, v: P[keyof P]) => null | S
make: (k: keyof P, v: P[keyof P]) => null | S,
): S[] {
const result: S[] = [];
for (const [key, value] of Object.entries(src)) {
@@ -31,7 +31,7 @@ export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.nor
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
export const qmc2ProductionToStaging = (
key: keyof ProductionQMCv2Keys,
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys],
): StagingQMCv2Key => {
return {
id: nanoid(),
@@ -44,7 +44,13 @@ export const qmc2ProductionToStaging = (
export interface StagingKWMv2Key {
id: string;
/**
* Resource ID
*/
rid: string;
/**
* Quality String
*/
quality: string;
ekey: string;
}
@@ -58,16 +64,17 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
return { rid, quality };
};
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`;
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
export const kwm2ProductionToStaging = (
key: keyof ProductionKWMv2Keys,
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys],
): null | StagingKWMv2Key => {
if (typeof value !== 'string') return null;
const parsed = parseKwm2ProductionKey(key);
if (!parsed) return null;
const { quality, rid } = parsed;
return { id: nanoid(), rid: parsed.rid, quality: parsed.quality, ekey: value };
return { id: nanoid(), rid, quality, ekey: value };
};

View File

@@ -0,0 +1,33 @@
import { Code, ListItem, OrderedList, Text, chakra } from '@chakra-ui/react';
const KUWO_IOS_DIR = '/var/mobile/Containers/Data/Application/<酷我数据目录>/mmkv';
export function InstructionsIOS() {
return (
<>
<Text>访 iOS </Text>
<Text>
<chakra.span color="red.400"></chakra.span>
</Text>
<OrderedList>
<ListItem>
<Text>
访
<Code wordBreak="break-word">{KUWO_IOS_DIR}</Code>
</Text>
</ListItem>
<ListItem>
<Text>
<Code>kw_ekey</Code> 访
</Text>
</ListItem>
<ListItem>
<Text>
<Code>kw_ekey</Code>
</Text>
</ListItem>
</OrderedList>
</>
);
}

View File

@@ -1,12 +1,14 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
import { InstructionsPC } from './InstructionsPC';
import { InstructionsIOS } from './InstructionsIOS';
export function KWMv2AllInstructions() {
return (
<>
<TabList>
<Tab></Tab>
<Tab>iOS</Tab>
<Tab>Windows</Tab>
</TabList>
<TabPanels flex={1} overflow="auto">
@@ -16,6 +18,9 @@ export function KWMv2AllInstructions() {
file="cn.kuwo.player.mmkv.defaultconfig"
/>
</TabPanel>
<TabPanel>
<InstructionsIOS />
</TabPanel>
<TabPanel>
<InstructionsPC />
</TabPanel>

View File

@@ -22,7 +22,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
import { ImportSecretModal } from '~/components/ImportSecretModal';
import { MMKVParser } from '~/util/MMKVParser';
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '~/util/mmkv/kuwo';
import { kwm2AddKey, kwm2ClearKeys, kwm2ImportKeys } from '../settingsSlice';
import { selectStagingKWMv2Keys } from '../settingsSelector';
@@ -41,9 +41,11 @@ export function PanelKWMv2Key() {
const handleSecretImport = async (file: File) => {
let keys: Omit<StagingKWMv2Key, 'id'>[] | null = null;
if (/cn\.kuwo\.player\.mmkv/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer();
keys = MMKVParser.parseKuwoEKey(new DataView(fileBuffer));
keys = parseAndroidKuwoEKey(new DataView(await file.arrayBuffer()));
} else if (/kw_ekey/.test(file.name)) {
keys = parseIosKuwoEKey(new DataView(await file.arrayBuffer()));
}
if (keys?.length === 0) {
toast({
title: '未导入密钥',

View File

@@ -14,6 +14,7 @@ import {
MenuDivider,
MenuItem,
MenuList,
Select,
Text,
Tooltip,
useToast,
@@ -28,15 +29,17 @@ import { InfoOutlineIcon } from '@chakra-ui/icons';
import { ImportSecretModal } from '~/components/ImportSecretModal';
import { StagingQMCv2Key } from '../keyFormats';
import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor';
import { MMKVParser } from '~/util/MMKVParser';
import { parseAndroidQmEKey } from '~/util/mmkv/qm';
import { getFileName } from '~/util/pathHelper';
import { QMCv2AllInstructions } from './QMCv2/QMCv2AllInstructions';
import { QMCv2QQMusicAllInstructions } from './QMCv2/QMCv2QQMusicAllInstructions';
import { QMCv2DoubanAllInstructions } from './QMCv2/QMCv2DoubanAllInstructions';
export function PanelQMCv2Key() {
const toast = useToast();
const dispatch = useDispatch();
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
const [showImportModal, setShowImportModal] = useState(false);
const [secretType, setSecretType] = useState<'qm' | 'douban'>('qm');
const addKey = () => dispatch(qmc2AddKey());
const clearAll = () => dispatch(qmc2ClearKeys());
@@ -51,16 +54,16 @@ export function PanelQMCv2Key() {
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
if (/[_.]db$/i.test(file.name)) {
if (/(player_process[_.]db|music_audio_play)(\.db)?$/i.test(file.name)) {
const extractor = await DatabaseKeyExtractor.getInstance();
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
qmc2Keys = extractor.extractQmcV2KeysFromSqliteDb(fileBuffer);
if (!qmc2Keys) {
alert(`不是支持的 SQLite 数据库文件。\n表名${qmc2Keys}`);
alert(`不是支持的 SQLite 数据库文件。`);
return;
}
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
} else if (/MMKVStreamEncryptId|filenameEkeyMap|qmpc-mmkv-v1/i.test(file.name)) {
const fileBuffer = await file.arrayBuffer();
const map = MMKVParser.toStringMap(new DataView(fileBuffer));
const map = parseAndroidQmEKey(new DataView(fileBuffer));
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
}
@@ -96,8 +99,8 @@ export function PanelQMCv2Key() {
</Heading>
<Text>
QQ QMCv2使QQ Mac iOS
线
QQ FM QMCv2使QQ Mac iOS 使
FM线
</Text>
<HStack pb={2} pt={2}>
@@ -155,16 +158,28 @@ export function PanelQMCv2Key() {
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
))}
</List>
{qmc2Keys.length === 0 && <Text></Text>}
{qmc2Keys.length === 0 && <Text></Text>}
</Box>
<ImportSecretModal
clientName="QQ 音乐"
clientName={
<Select
value={secretType}
onChange={(e) => setSecretType(e.target.value as 'qm' | 'douban')}
variant="flushed"
display="inline"
css={{ paddingLeft: '0.75rem', width: 'auto' }}
>
<option value="qm">QQ </option>
<option value="douban"> FM</option>
</Select>
}
show={showImportModal}
onClose={() => setShowImportModal(false)}
onImport={handleSecretImport}
>
<QMCv2AllInstructions />
{secretType === 'qm' && <QMCv2QQMusicAllInstructions />}
{secretType === 'douban' && <QMCv2DoubanAllInstructions />}
</ImportSecretModal>
</Flex>
);

View File

@@ -0,0 +1,133 @@
import {
Box,
Code,
Flex,
FormControl,
FormHelperText,
FormLabel,
Heading,
Input,
ListItem,
Text,
UnorderedList,
} 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';
const QTFM_DEVICE_ID_URL = 'https://github.com/parakeet-rs/qtfm-device-id/releases/latest';
export function PanelQingTing() {
const dispatch = useAppDispatch();
const secretKey = useAppSelector(selectStagingQtfmAndroidKey);
const setSecretKey = (secretKey: string) => {
dispatch(qtfmAndroidUpdateKey({ deviceKey: secretKey }));
};
const handleDataPaste = (e: ClipboardEvent<HTMLInputElement>) => {
const plainText = e.clipboardData.getData('text/plain');
const matchDeviceSecret = plainText.match(/^DEVICE_SECRET: ([0-9a-fA-F]+)/m);
if (matchDeviceSecret) {
e.preventDefault();
setSecretKey(matchDeviceSecret[1]);
return;
}
const dataMap = new Map();
for (const [_unused, key, value] of plainText.matchAll(
/^(PRODUCT|DEVICE|MANUFACTURER|BRAND|BOARD|MODEL): (.+)/gim,
)) {
dataMap.set(key.toLowerCase(), value);
}
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
) {
e.preventDefault();
fetchParakeet().then((parakeet) => {
setSecretKey(parakeet.qtfm.createDeviceKey(product, device, manufacturer, brand, board, model));
});
}
};
const handleDataInput = (e: ChangeEvent<HTMLInputElement>) => {
setSecretKey(e.target.value);
};
return (
<Flex minH={0} flexDir="column" flex={1}>
<Heading as="h2" size="lg">
<VQuote> FM</VQuote>
</Heading>
<Text>
<VQuote> FM</VQuote>
</Text>
<Box mt={3} mb={3}>
<FormControl>
<FormLabel></FormLabel>
<Input type="text" onPaste={handleDataPaste} value={secretKey} onChange={handleDataInput} />
<FormHelperText>
{'粘贴含有设备密钥的信息的内容时将自动提取密钥(如通过 '}
<ExtLink href={QTFM_DEVICE_ID_URL}>
<Code>qtfm-device-id</Code>
</ExtLink>
{' 获取的设备信息)。'}
</FormHelperText>
</FormControl>
</Box>
<Heading as="h3" size="md" pt={3} pb={2}>
</Heading>
<UnorderedList>
<ListItem>
<Text>
<Code>[]/Android/data/fm.qingting.qtradio/files/Music/</Code>
</Text>
<UnorderedList>
<ListItem>
<Text>
使
<ruby>
<rp> (</rp>
<rt>root</rt>
<rp>)</rp>
</ruby>
访
</Text>
</ListItem>
</UnorderedList>
</ListItem>
<ListItem>
<Text>
<Code>.p~!</Code>
</Text>
</ListItem>
<ListItem>
<Text></Text>
</ListItem>
</UnorderedList>
</Flex>
);
}

View File

@@ -3,7 +3,8 @@ import { Text } from '@chakra-ui/react';
export function InstructionsPC() {
return (
<>
<Text>使 Windows </Text>
<Text>使 Windows 19.43 </Text>
<Text>使 Windows 19.51 </Text>
</>
);
}

View File

@@ -0,0 +1,17 @@
import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
export function QMCv2DoubanAllInstructions() {
return (
<>
<TabList>
<Tab></Tab>
</TabList>
<TabPanels flex={1} overflow="auto">
<TabPanel>
<AndroidADBPullInstruction dir="/data/data/com.douban.radio/databases" file="music_audio_play" />
</TabPanel>
</TabPanels>
</>
);
}

View File

@@ -4,7 +4,7 @@ import { InstructionsIOS } from './InstructionsIOS';
import { InstructionsMac } from './InstructionsMac';
import { InstructionsPC } from './InstructionsPC';
export function QMCv2AllInstructions() {
export function QMCv2QQMusicAllInstructions() {
return (
<>
<TabList>

View File

@@ -0,0 +1 @@
// TODO: Popup dialog for QingTing instructions

View File

@@ -1,16 +1,16 @@
import { debounce } from 'radash';
import { produce } from 'immer';
import type { AppStore } from '~/store';
import { settingsSlice, setProductionChanges, ProductionSettings } from './settingsSlice';
import { enumObject } from '~/util/objects';
import { getLogger } from '~/util/logUtils';
import { parseKwm2ProductionKey } from './keyFormats';
import { deepClone } from '~/util/deepClone';
const DEFAULT_STORAGE_KEY = 'um-react-settings';
function mergeSettings(settings: ProductionSettings): ProductionSettings {
return produce(settingsSlice.getInitialState().production, (draft) => {
const draft = deepClone(settingsSlice.getInitialState().production);
if (settings?.qmc2) {
const { allowFuzzyNameSearch, keys } = settings.qmc2;
for (const [k, v] of enumObject(keys)) {
@@ -33,7 +33,12 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
}
}
}
});
if (typeof settings?.qtfm?.android === 'string') {
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
}
return draft;
}
export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KEY) {
@@ -58,6 +63,6 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
localStorage.setItem(storageKey, JSON.stringify(currentSettings));
getLogger().debug('settings saved');
}
})
}),
);
}

View File

@@ -47,3 +47,6 @@ export const selectKWMv2Key = (state: RootState, headerView: DataView): string |
return ekey;
};
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;

View File

@@ -24,6 +24,9 @@ export interface StagingSettings {
kwm2: {
keys: StagingKWMv2Key[];
};
qtfm: {
android: string;
};
}
export interface ProductionSettings {
@@ -34,6 +37,9 @@ export interface ProductionSettings {
kwm2: {
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
};
qtfm: {
android: string;
};
}
export interface SettingsState {
@@ -46,10 +52,12 @@ const initialState: SettingsState = {
staging: {
qmc2: { allowFuzzyNameSearch: true, keys: [] },
kwm2: { keys: [] },
qtfm: { android: '' },
},
production: {
qmc2: { allowFuzzyNameSearch: true, keys: {} },
kwm2: { keys: {} },
qtfm: { android: '' },
},
};
@@ -61,6 +69,7 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
kwm2: {
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
},
qtfm: staging.qtfm,
});
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
@@ -71,6 +80,7 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
kwm2: {
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
},
qtfm: production.qtfm,
});
export const settingsSlice = createSlice({
@@ -101,7 +111,7 @@ export const settingsSlice = createSlice({
},
qmc2UpdateKey(
state,
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }>
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingQMCv2Key; value: string }>,
) {
const keyItem = state.staging.qmc2.keys.find((item) => item.id === id);
if (keyItem) {
@@ -134,7 +144,7 @@ export const settingsSlice = createSlice({
},
kwm2UpdateKey(
state,
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }>
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKWMv2Key; value: string }>,
) {
const keyItem = state.staging.kwm2.keys.find((item) => item.id === id);
if (keyItem) {
@@ -142,6 +152,10 @@ export const settingsSlice = createSlice({
state.dirty = true;
}
},
qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
state.staging.qtfm.android = deviceKey;
state.dirty = true;
},
kwm2ClearKeys(state) {
state.staging.kwm2.keys = [];
state.dirty = true;
@@ -183,6 +197,8 @@ export const {
kwm2ClearKeys,
kwm2ImportKeys,
qtfmAndroidUpdateKey,
commitStagingChange,
discardStagingChanges,
} = settingsSlice.actions;

View File

@@ -1,3 +1,4 @@
import './pwa';
import React from 'react';
import ReactDOM from 'react-dom/client';
@@ -13,5 +14,5 @@ SyntaxHighlighter.registerLanguage('bash', hljsSyntaxBash);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<AppRoot />
</React.StrictMode>
</React.StrictMode>,
);

10
src/pwa.ts Normal file
View File

@@ -0,0 +1,10 @@
import { registerSW } from 'virtual:pwa-register';
const updateSW = registerSW({
onNeedRefresh() {
if (confirm('应用程序已更新,是否刷新?')) {
updateSW();
}
},
onOfflineReady() {},
});

View File

@@ -1,4 +1,4 @@
import { PreloadedState, combineReducers, configureStore } from '@reduxjs/toolkit';
import { combineReducers, configureStore } from '@reduxjs/toolkit';
import fileListingReducer from './features/file-listing/fileListingSlice';
import settingsReducer from './features/settings/settingsSlice';
@@ -7,12 +7,13 @@ const rootReducer = combineReducers({
settings: settingsReducer,
});
export const setupStore = (preloadedState?: PreloadedState<RootState>) =>
export type RootState = ReturnType<typeof rootReducer>;
export const setupStore = (preloadedState?: Partial<RootState>) =>
configureStore({
reducer: rootReducer,
preloadedState,
});
export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore['dispatch'];

View File

@@ -1,4 +1,3 @@
import { PreloadedState } from '@reduxjs/toolkit';
import { RenderOptions, render } from '@testing-library/react';
import { PropsWithChildren } from 'react';
import { Provider } from 'react-redux';
@@ -10,13 +9,13 @@ import { AppStore, RootState, setupStore } from '~/store';
export * from '@testing-library/react';
export interface ExtendedRenderOptions extends RenderOptions {
preloadedState?: PreloadedState<RootState>;
preloadedState?: Partial<RootState>;
store?: AppStore;
}
export function renderWithProviders(
ui: React.ReactElement,
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {}
{ preloadedState = {}, store = setupStore(preloadedState), ...renderOptions }: ExtendedRenderOptions = {},
) {
function Wrapper({ children }: PropsWithChildren<unknown>): JSX.Element {
return <Provider store={store}>{children}</Provider>;

View File

@@ -9,6 +9,12 @@ export const theme = extendTheme({
'Segoe UI,Helvetica,Arial,sans-serif',
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
].join(','),
mono: [
'SFMono-Regular,Menlo,Monaco',
'"Sarasa Mono CJK SC"',
'Consolas,"Liberation Mono","Courier New",monospace',
'"Microsoft YaHei UI"',
].join(','),
},
components: {
Button: {
@@ -25,6 +31,16 @@ export const theme = extendTheme({
color: 'blue.600',
},
},
Text: {
baseStyle: {
mt: 1,
},
},
Header: {
baseStyle: {
mt: 3,
},
},
},
styles: {
global: {

View File

@@ -23,16 +23,21 @@ export class DatabaseKeyExtractor {
return tables.includes(name);
}
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
extractQmcV2KeysFromSqliteDb(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
let db: SQLDatabase | null = null;
try {
db = new this.SQL.Database(new Uint8Array(buffer));
if (!this.hasTable(db, 'audio_file_ekey_table')) {
return null;
}
const result = db.exec('select file_path, ekey from audio_file_ekey_table');
let sql: undefined | string;
if (this.hasTable(db, 'audio_file_ekey_table')) {
sql = 'select file_path, ekey from audio_file_ekey_table';
} else if (this.hasTable(db, 'EKeyFileInfo')) {
sql = 'select filePath, eKey from EKeyFileInfo';
}
if (!sql) return null;
const result = db.exec(sql);
if (result.length === 0) {
return [];
}

View File

@@ -1,4 +1,3 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
import { formatHex } from './formatHex';
@@ -69,7 +68,11 @@ export class MMKVParser {
return bytesToUTF8String(data).normalize();
}
public readOptionalString() {
public readKey() {
return this.readString();
}
public readStringValue(): string | null {
// Container [
// len: int,
// data: variant
@@ -96,37 +99,4 @@ export class MMKVParser {
const containerLen = this.readInt();
this.offset += containerLen;
}
public static toStringMap(view: DataView): Map<string, string> {
const mmkv = new MMKVParser(view);
const result = new Map<string, string>();
while (!mmkv.eof) {
const key = mmkv.readString();
const value = mmkv.readOptionalString();
if (value) {
result.set(key, value);
}
}
return result;
}
public static parseKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) {
const key = mmkv.readString();
const idMatch = key.match(/^sec_ekey#(\d+)-(.+)/);
if (!idMatch) {
mmkv.skipContainer();
continue;
}
const [_, rid, quality] = idMatch;
const ekey = mmkv.readOptionalString();
if (ekey) {
result.push({ rid, quality, ekey });
}
}
return result;
}
}

View File

@@ -18,15 +18,18 @@ test('should be able to forward request to worker client bus', async () => {
vi.spyOn(bus, 'request').mockImplementation(
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
return { actionName, payload };
}
},
);
const queue = new DecryptionQueue(bus, 1);
await expect(queue.add({ id: 'file://1', blobURI: 'blob://mock-file' })).resolves.toEqual({
await expect(
queue.add({ id: 'file://1', blobURI: 'blob://mock-file', options: { fileName: 'test.bin' } }),
).resolves.toEqual({
actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT,
payload: {
blobURI: 'blob://mock-file',
id: 'file://1',
options: { fileName: 'test.bin' },
},
});
});

View File

@@ -1,46 +1,28 @@
import { MMKVParser } from '../MMKVParser';
import { readFileSync } from 'node:fs';
const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse qm mmkv file', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
{
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
"key": "value",
}
`);
});
test('parse qm mmkv file with optional str', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm_optional.mmkv'));
expect(Object.fromEntries(MMKVParser.toStringMap(view).entries())).toMatchInlineSnapshot(`
{
"key": "value",
"key2": "value2",
}
`);
});
test('parse kuwo mmkv file', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo.mmkv'));
expect(MMKVParser.parseKuwoEKey(view)).toMatchInlineSnapshot(`
[
{
"ekey": "xyz123",
"quality": "20201kmflac",
"rid": "1234567",
},
]
`);
});
test('throw error on broken file', () => {
const view = makeViewFromBuffer(
Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]),
);
expect(() => Object.fromEntries(MMKVParser.toStringMap(view).entries())).toThrow(/offset mismatch/i);
expect(() => {
const parser = new MMKVParser(view);
parser.readKey();
parser.readStringValue();
}).toThrow(/offset mismatch/i);
});
test('able to handle empty value', () => {
const view = makeViewFromBuffer(
Buffer.from([0x0b, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x00, 0x01, 0x31, 0x02, 0x01, 0x32, 0xff]),
);
const parser = new MMKVParser(view);
expect(parser.readKey()).toEqual('key');
expect(parser.readStringValue()).toEqual(null);
expect(parser.readKey()).toEqual('1');
expect(parser.readStringValue()).toEqual('2');
});

3
src/util/deepClone.ts Normal file
View File

@@ -0,0 +1,3 @@
export function deepClone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}

Binary file not shown.

View File

@@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import { parseAndroidKuwoEKey, parseIosKuwoEKey } from '../kuwo';
const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse kuwo android ekey mmkv file "cn.kuwo.player.mmkv.defaultconfig"', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_android.mmkv'));
expect(parseAndroidKuwoEKey(view)).toMatchInlineSnapshot(`
[
{
"ekey": "xyz123",
"quality": "20201kmflac",
"rid": "1234567",
},
]
`);
});
test('parse kuwo ios ekey mmkv file "kw_ekey"', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/kuwo_ios.mmkv'));
expect(parseIosKuwoEKey(view)).toMatchInlineSnapshot(`
[
{
"ekey": "xyz123",
"quality": "20201",
"rid": "1234567",
},
]
`);
});

View File

@@ -0,0 +1,15 @@
import { readFileSync } from 'node:fs';
import { parseAndroidQmEKey } from '../qm';
const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength));
test('parse qm mmkv file', () => {
const view = makeViewFromBuffer(readFileSync(__dirname + '/__fixture__/qm.mmkv'));
expect(Object.fromEntries(parseAndroidQmEKey(view).entries())).toMatchInlineSnapshot(`
{
"Lorem Ipsum": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum congue volutpat metus non molestie. Quisque id est sapien. Fusce eget tristique sem. Donec tellus lacus, viverra sed lectus eget, elementum ultrices dolor. Integer non urna justo.",
"key": "value",
}
`);
});

41
src/util/mmkv/kuwo.ts Normal file
View File

@@ -0,0 +1,41 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { MMKVParser } from '../MMKVParser';
export function parseAndroidKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) {
const key = mmkv.readString();
const idMatch = key.match(/^sec_ekey#(\d+)-(\w+)$/);
if (!idMatch) {
mmkv.skipContainer();
continue;
}
const [_, rid, quality] = idMatch;
const ekey = mmkv.readStringValue();
if (ekey) {
result.push({ rid, quality, ekey });
}
}
return result;
}
export function parseIosKuwoEKey(view: DataView): Omit<StagingKWMv2Key, 'id'>[] {
const mmkv = new MMKVParser(view);
const result: Omit<StagingKWMv2Key, 'id'>[] = [];
while (!mmkv.eof) {
const key = mmkv.readKey();
const idMatch = key.match(/^(\d+)_(\d+)$/);
if (!idMatch) {
mmkv.skipContainer();
continue;
}
const [_, rid, quality] = idMatch;
const ekey = mmkv.readStringValue();
if (ekey) {
result.push({ rid, quality, ekey });
}
}
return result;
}

14
src/util/mmkv/qm.ts Normal file
View File

@@ -0,0 +1,14 @@
import { MMKVParser } from '../MMKVParser';
export function parseAndroidQmEKey(view: DataView): Map<string, string> {
const mmkv = new MMKVParser(view);
const result = new Map<string, string>();
while (!mmkv.eof) {
const key = mmkv.readString();
const value = mmkv.readStringValue();
if (value) {
result.set(key, value);
}
}
return result;
}

8
src/vite-env.d.ts vendored
View File

@@ -1 +1,9 @@
/// <reference types="vite/client" />
module 'virtual:pwa-register' {
/**
* See: {@link https://vite-pwa-org.netlify.app/guide/prompt-for-update.html}
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
declare function registerSW(opts: unknown): () => void;
}

View File

@@ -57,7 +57,7 @@ export default defineConfig({
registerType: 'prompt',
workbox: {
// Cache everything from dist
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm}'],
globPatterns: ['**/*.{js,css,html,ico,png,svg,wasm,webp}'],
},
manifest: {
display: 'standalone',
@@ -86,6 +86,8 @@ export default defineConfig({
alias: {
'~': path.resolve(__dirname, 'src'),
'@nm': path.resolve(__dirname, 'node_modules'),
// workaround for vite, workbox (PWA) and Emscripten transpiled parakeet lib (use of `import("module")`)
module: path.resolve(__dirname, 'src', 'dummy.mjs'),
},
},
@@ -96,7 +98,7 @@ export default defineConfig({
reacts: ['react', 'react-dom', 'react-dropzone', 'react-promise-suspense', 'react-redux', '@reduxjs/toolkit'],
chakra: ['@chakra-ui/react', '@emotion/react', '@emotion/styled', 'framer-motion'],
icons: ['react-icons', '@chakra-ui/icons'],
utility: ['radash', 'nanoid', 'immer', 'react-syntax-highlighter'],
utility: ['radash', 'nanoid', 'react-syntax-highlighter'],
},
},
},
@@ -106,15 +108,8 @@ export default defineConfig({
mockReset: true,
environment: 'jsdom',
setupFiles: ['src/test-utils/setup-jest.ts'],
alias: [
{
find: /^~\/(.*)/,
replacement: 'src/$1',
},
],
// workaround: sql.js is not ESModule friendly, yet...
deps: {
// inline: ['sql.js'],
optimizer: {
web: {
include: ['sql.js'],