28 Commits

Author SHA1 Message Date
鲁树人
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
52 changed files with 2287 additions and 1756 deletions

View File

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

4
.npmrc
View File

@@ -1,3 +1,3 @@
use-node-version=18.16.0 use-node-version=20.8.1
node-version=18.16.0 node-version=20.8.1
engine-strict=true 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) [![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. - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- 查看[原基于 Vue 的 Unlock Music 项目][um-vue]
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。 - Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。 - Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入! - 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
@@ -10,14 +11,19 @@
- [常见问题参考](./docs/faq_zh-hans.md) - [常见问题参考](./docs/faq_zh-hans.md)
[授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE [授权协议]: https://git.unlock-music.dev/um/um-react/src/branch/main/LICENSE
[um-vue]: https://git.unlock-music.dev/um/web
[unlock-music/cli]: https://git.unlock-music.dev/um/cli [unlock-music/cli]: https://git.unlock-music.dev/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat [`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/ [um-react-packages]: https://git.unlock-music.dev/um/-/packages/generic/um-react/
## 支持的格式 ## 支持的格式
- [x] QQ 音乐 QMCv1 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm) - [x] QQ 音乐 QMCv1 (..qmc3/.qmcflac)
- [x] QQ 音乐 QMCv2 PC 端 (.mflac/.mgg/.mflac0/.mgg1/.mggl) - [x] QQ 音乐 QMCv2
- 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] 网易云音乐 (.ncm)
- [x] 虾米音乐 (.xm) - [x] 虾米音乐 (.xm)
- [x] 酷我音乐 (.kwm) - [x] 酷我音乐 (.kwm)
@@ -26,6 +32,11 @@
- [x] 咪咕音乐格式 (.mg3d) - [x] 咪咕音乐格式 (.mg3d)
- [ ] ~~<ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)~~ - [ ] ~~<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/) 等。
如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。 如果遇到解密出错的情况,请一并携带错误信息并简单描述错误的重现过程。

View File

@@ -1,7 +1,7 @@
{ {
"name": "um-react", "name": "um-react",
"private": true, "private": true,
"version": "0.2.1", "version": "0.2.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "vite", "start": "vite",
@@ -16,56 +16,57 @@
"prepare": "husky install" "prepare": "husky install"
}, },
"dependencies": { "dependencies": {
"@chakra-ui/anatomy": "^2.2.1", "@chakra-ui/anatomy": "^2.2.2",
"@chakra-ui/icons": "^2.1.1", "@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.1", "@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@jixun/libparakeet": "0.3.0", "@jixun/libparakeet": "0.4.2",
"@reduxjs/toolkit": "^1.9.7", "@reduxjs/toolkit": "^2.0.1",
"framer-motion": "^10.16.4", "framer-motion": "^10.16.16",
"immer": "^10.0.3", "immer": "^10.0.3",
"nanoid": "^5.0.1", "nanoid": "^5.0.4",
"radash": "^11.0.0", "radash": "^11.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-dropzone": "^14.2.3", "react-dropzone": "^14.2.3",
"react-icons": "^4.11.0", "react-icons": "^4.12.0",
"react-promise-suspense": "^0.3.4", "react-promise-suspense": "^0.3.4",
"react-redux": "^8.1.3", "react-redux": "^9.0.4",
"react-syntax-highlighter": "^15.5.0", "react-syntax-highlighter": "^15.5.0",
"sass": "^1.69.2", "sass": "^1.69.5",
"sql.js": "^1.8.0" "sql.js": "^1.9.0"
}, },
"devDependencies": { "devDependencies": {
"@rollup/plugin-replace": "^5.0.3", "@rollup/plugin-replace": "^5.0.5",
"@testing-library/jest-dom": "^6.1.3", "@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.0.0", "@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1", "@testing-library/user-event": "^14.5.1",
"@types/node": "^20.8.4", "@types/node": "^20.10.5",
"@types/react": "^18.2.28", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.13", "@types/react-dom": "^18.2.18",
"@types/react-syntax-highlighter": "^15.5.8", "@types/react-syntax-highlighter": "^15.5.11",
"@types/sql.js": "^1.4.5", "@types/sql.js": "^1.4.9",
"@typescript-eslint/eslint-plugin": "^6.7.5", "@typescript-eslint/eslint-plugin": "^6.15.0",
"@typescript-eslint/parser": "^6.7.5", "@typescript-eslint/parser": "^6.15.0",
"@vitejs/plugin-react": "^4.1.0", "@vitejs/plugin-react": "^4.2.1",
"@vitest/coverage-v8": "^0.34.6", "@vitest/coverage-v8": "^1.1.0",
"@vitest/ui": "^0.34.6", "@vitest/ui": "^1.1.0",
"eslint": "^8.51.0", "eslint": "^8.56.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3", "eslint-plugin-react-refresh": "^0.4.5",
"husky": "^8.0.3", "husky": "^8.0.3",
"jsdom": "^22.1.0", "jsdom": "^23.0.1",
"lint-staged": "^14.0.1", "lint-staged": "^15.2.0",
"prettier": "^3.0.3", "prettier": "^3.1.1",
"typescript": "^5.2.2", "typescript": "^5.3.3",
"vite": "^4.4.11", "vite": "^5.0.10",
"vite-plugin-pwa": "^0.16.5", "vite-plugin-pwa": "^0.17.4",
"vite-plugin-top-level-await": "^1.3.1", "vite-plugin-top-level-await": "^1.4.1",
"vite-plugin-wasm": "^3.2.2", "vite-plugin-wasm": "^3.3.0",
"vitest": "^0.34.6" "vitest": "^1.1.0",
"workbox-window": "^7.0.0"
}, },
"lint-staged": { "lint-staged": {
"*": "prettier --write --ignore-unknown", "*": "prettier --write --ignore-unknown",
@@ -79,7 +80,7 @@
"pnpm": { "pnpm": {
"patchedDependencies": { "patchedDependencies": {
"@rollup/plugin-terser@0.4.3": "patches/@rollup__plugin-terser@0.4.3.patch", "@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": { "overrides": {
"rollup-plugin-terser": "npm:@rollup/plugin-terser@0.4.3", "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 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 --- a/dist/sql-wasm.js
+++ b/dist/sql-wasm.js +++ b/dist/sql-wasm.js
@@ -192,3 +192,7 @@ else if (typeof define === 'function' && define['amd']) { @@ -190,3 +190,6 @@ else if (typeof define === 'function' && define['amd']) {
else if (typeof exports === 'object'){ else if (typeof exports === 'object'){
exports["Module"] = initSqlJs; exports["Module"] = initSqlJs;
} }
+ +
+var module; +var module;
+export default initSqlJs; +export default initSqlJs;
+

3259
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

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> <FileInput onReceiveFiles={handleFileReceived}></FileInput>
</Center> </Center>
<Text mt={2}>{clientName && <>{clientName}</>}</Text> <Text as="div" mt={2}>
{clientName && <>{clientName}</>}
</Text>
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}> <Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
{children} {children}
</Flex> </Flex>

View File

@@ -7,9 +7,11 @@ export interface KuwoHeader {
quality: string; quality: string;
} }
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
export function parseKuwoHeader(view: DataView): KuwoHeader | null { export function parseKuwoHeader(view: DataView): KuwoHeader | null {
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10); 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 return null; // not kuwo-encrypted file
} }

View File

@@ -9,6 +9,7 @@ import { XimalayaAndroidCrypto } from './xmly/xmly_android';
import { KWMCrypto } from './kwm/kwm'; import { KWMCrypto } from './kwm/kwm';
import { MiguCrypto } from './migu/migu3d_keyless'; import { MiguCrypto } from './migu/migu3d_keyless';
import { TransparentCrypto } from './transparent/transparent'; import { TransparentCrypto } from './transparent/transparent';
import { QingTingFM$Device } from './qtfm/qtfm_device';
export const allCryptoFactories: CryptoFactory[] = [ export const allCryptoFactories: CryptoFactory[] = [
// Xiami (*.xm) // Xiami (*.xm)
@@ -40,6 +41,9 @@ export const allCryptoFactories: CryptoFactory[] = [
XimalayaAndroidCrypto.makeX2M, XimalayaAndroidCrypto.makeX2M,
XimalayaAndroidCrypto.makeX3M, XimalayaAndroidCrypto.makeX3M,
// QingTingFM (Android)
QingTingFM$Device.make,
// Transparent crypto (not encrypted) // Transparent crypto (not encrypted)
TransparentCrypto.make, TransparentCrypto.make,
]; ];

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 { export interface DecryptCommandOptions {
fileName: string;
qmc2Key?: string; qmc2Key?: string;
kwm2key?: string; kwm2key?: string;
qingTingAndroidKey?: string;
} }
export interface DecryptCommandPayload { export interface DecryptCommandPayload {

View File

@@ -1,2 +1,5 @@
// This is a dummy module for vite/rollup to resolve. // This is a dummy module for vite/rollup to resolve.
Object.defineProperty(Object.create(null), { sideEffects: true }); export function createRequire() {
import('immer'); // 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

@@ -1,5 +1,5 @@
import { ExternalLinkIcon } from '@chakra-ui/icons'; import { Text } from '@chakra-ui/react';
import { Link, Text } from '@chakra-ui/react'; import { ExtLink } from '~/components/ExtLink';
import { Header4 } from '~/components/HelpText/Header4'; import { Header4 } from '~/components/HelpText/Header4';
import { ProjectIssue } from '~/components/ProjectIssue'; import { ProjectIssue } from '~/components/ProjectIssue';
@@ -17,10 +17,7 @@ export function OtherFAQ() {
<Header4></Header4> <Header4></Header4>
<Text> <Text>
{'欢迎进入 '} {'欢迎进入 '}
<Link href={'https://t.me/unlock_music_chat'} isExternal> <ExtLink href={'https://t.me/unlock_music_chat'}>Telegram - </ExtLink>
Telegram -
<ExternalLinkIcon />
</Link>
{' 一起探讨。'} {' 一起探讨。'}
</Text> </Text>
</> </>

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 { Header4 } from '~/components/HelpText/Header4';
import { SegmentTryOfficialPlayer } from './SegmentTryOfficialPlayer'; 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 { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
import { ExtLink } from '~/components/ExtLink';
export function QQMusicFAQ() { export function QQMusicFAQ() {
return ( return (
@@ -16,7 +17,26 @@ export function QQMusicFAQ() {
<Text> <Text>
<chakra.strong>2</chakra.strong> <chakra.strong>2</chakra.strong>
</Text> </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}> <Container p={2}>
<Alert status="warning" borderRadius={5}> <Alert status="warning" borderRadius={5}>
@@ -35,7 +55,7 @@ export function QQMusicFAQ() {
</Alert> </Alert>
</Container> </Container>
<SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2AllInstructions />} /> <SegmentKeyImportInstructions tab="QMCv2 密钥" clientInstructions={<QMCv2QQMusicAllInstructions />} />
</ListItem> </ListItem>
</List> </List>
</> </>

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import type { DecryptionResult } from '~/decrypt-worker/constants';
import type { DecryptCommandOptions } from '~/decrypt-worker/types'; import type { DecryptCommandOptions } from '~/decrypt-worker/types';
import { decryptionQueue } from '~/decrypt-worker/client'; import { decryptionQueue } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
import { selectQMCv2KeyByFileName, selectKWMv2Key } from '../settings/settingsSelector'; import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector';
export enum ProcessState { export enum ProcessState {
QUEUED = 'QUEUED', QUEUED = 'QUEUED',
@@ -73,8 +73,10 @@ export const processFile = createAsyncThunk<
.then((r) => r.arrayBuffer()); .then((r) => r.arrayBuffer());
const options: DecryptCommandOptions = { const options: DecryptCommandOptions = {
fileName: file.fileName,
qmc2Key: selectQMCv2KeyByFileName(state, file.fileName), qmc2Key: selectQMCv2KeyByFileName(state, file.fileName),
kwm2key: selectKWMv2Key(state, new DataView(fileHeader)), kwm2key: selectKWMv2Key(state, new DataView(fileHeader)),
qingTingAndroidKey: selectQtfmAndroidKey(state),
}; };
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess); 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 { commitStagingChange, discardStagingChanges } from './settingsSlice';
import { PanelKWMv2Key } from './panels/PanelKWMv2Key'; import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
import { selectIsSettingsNotSaved } from './settingsSelector'; import { selectIsSettingsNotSaved } from './settingsSelector';
import { PanelQingTing } from './panels/PanelQingTing';
const TABS: { name: string; Tab: () => JSX.Element }[] = [ const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key }, { name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key }, { name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
{ name: '蜻蜓 FM', Tab: PanelQingTing },
{ {
name: '其它/待定', name: '其它/待定',
Tab: () => <Text></Text>, Tab: () => <Text></Text>,

View File

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

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

View File

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

View File

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

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

@@ -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 { InstructionsMac } from './InstructionsMac';
import { InstructionsPC } from './InstructionsPC'; import { InstructionsPC } from './InstructionsPC';
export function QMCv2AllInstructions() { export function QMCv2QQMusicAllInstructions() {
return ( return (
<> <>
<TabList> <TabList>

View File

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

View File

@@ -33,6 +33,10 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
} }
} }
} }
if (typeof settings?.qtfm?.android === 'string') {
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
}
}); });
} }
@@ -58,6 +62,6 @@ export function persistSettings(store: AppStore, storageKey = DEFAULT_STORAGE_KE
localStorage.setItem(storageKey, JSON.stringify(currentSettings)); localStorage.setItem(storageKey, JSON.stringify(currentSettings));
getLogger().debug('settings saved'); getLogger().debug('settings saved');
} }
}) }),
); );
} }

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
import './pwa';
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
@@ -13,5 +14,5 @@ SyntaxHighlighter.registerLanguage('bash', hljsSyntaxBash);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<AppRoot /> <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 fileListingReducer from './features/file-listing/fileListingSlice';
import settingsReducer from './features/settings/settingsSlice'; import settingsReducer from './features/settings/settingsSlice';
@@ -7,12 +7,13 @@ const rootReducer = combineReducers({
settings: settingsReducer, settings: settingsReducer,
}); });
export const setupStore = (preloadedState?: PreloadedState<RootState>) => export type RootState = ReturnType<typeof rootReducer>;
export const setupStore = (preloadedState?: Partial<RootState>) =>
configureStore({ configureStore({
reducer: rootReducer, reducer: rootReducer,
preloadedState, preloadedState,
}); });
export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>; export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore['dispatch']; export type AppDispatch = AppStore['dispatch'];

View File

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

View File

@@ -9,6 +9,12 @@ export const theme = extendTheme({
'Segoe UI,Helvetica,Arial,sans-serif', 'Segoe UI,Helvetica,Arial,sans-serif',
'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol', 'Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol',
].join(','), ].join(','),
mono: [
'SFMono-Regular,Menlo,Monaco',
'"Sarasa Mono CJK SC"',
'Consolas,"Liberation Mono","Courier New",monospace',
'"Microsoft YaHei UI"',
].join(','),
}, },
components: { components: {
Button: { Button: {

View File

@@ -23,16 +23,21 @@ export class DatabaseKeyExtractor {
return tables.includes(name); return tables.includes(name);
} }
extractQmAndroidDbKeys(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] { extractQmcV2KeysFromSqliteDb(buffer: ArrayBuffer): null | QMAndroidKeyEntry[] {
let db: SQLDatabase | null = null; let db: SQLDatabase | null = null;
try { try {
db = new this.SQL.Database(new Uint8Array(buffer)); 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) { if (result.length === 0) {
return []; return [];
} }

View File

@@ -1,4 +1,3 @@
import type { StagingKWMv2Key } from '~/features/settings/keyFormats';
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder'; import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
import { formatHex } from './formatHex'; import { formatHex } from './formatHex';
@@ -69,7 +68,11 @@ export class MMKVParser {
return bytesToUTF8String(data).normalize(); return bytesToUTF8String(data).normalize();
} }
public readOptionalString() { public readKey() {
return this.readString();
}
public readStringValue(): string | null {
// Container [ // Container [
// len: int, // len: int,
// data: variant // data: variant
@@ -96,37 +99,4 @@ export class MMKVParser {
const containerLen = this.readInt(); const containerLen = this.readInt();
this.offset += containerLen; 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( vi.spyOn(bus, 'request').mockImplementation(
async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => { async (actionName: DECRYPTION_WORKER_ACTION_NAME, payload: unknown): Promise<unknown> => {
return { actionName, payload }; return { actionName, payload };
} },
); );
const queue = new DecryptionQueue(bus, 1); 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, actionName: DECRYPTION_WORKER_ACTION_NAME.DECRYPT,
payload: { payload: {
blobURI: 'blob://mock-file', blobURI: 'blob://mock-file',
id: 'file://1', id: 'file://1',
options: { fileName: 'test.bin' },
}, },
}); });
}); });

View File

@@ -1,46 +1,28 @@
import { MMKVParser } from '../MMKVParser'; import { MMKVParser } from '../MMKVParser';
import { readFileSync } from 'node:fs';
const makeViewFromBuffer = (buff: Buffer) => const makeViewFromBuffer = (buff: Buffer) =>
new DataView(buff.buffer.slice(buff.byteOffset, buff.byteOffset + buff.byteLength)); 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', () => { test('throw error on broken file', () => {
const view = makeViewFromBuffer( const view = makeViewFromBuffer(
Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]), Buffer.from([0x27, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x06, 0x07, 0x62, 0x61, 0x64, 0xff, 0xff]),
); );
expect(() => Object.fromEntries(MMKVParser.toStringMap(view).entries())).toThrow(/offset mismatch/i); expect(() => {
const parser = new MMKVParser(view);
parser.readKey();
parser.readStringValue();
}).toThrow(/offset mismatch/i);
});
test('able to handle empty value', () => {
const view = makeViewFromBuffer(
Buffer.from([0x0b, 0x00, 0x00, 0x00, 0x7f, 0x03, 0x6b, 0x65, 0x79, 0x00, 0x01, 0x31, 0x02, 0x01, 0x32, 0xff]),
);
const parser = new MMKVParser(view);
expect(parser.readKey()).toEqual('key');
expect(parser.readStringValue()).toEqual(null);
expect(parser.readKey()).toEqual('1');
expect(parser.readStringValue()).toEqual('2');
}); });

Binary file not shown.

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

@@ -86,6 +86,8 @@ export default defineConfig({
alias: { alias: {
'~': path.resolve(__dirname, 'src'), '~': path.resolve(__dirname, 'src'),
'@nm': path.resolve(__dirname, 'node_modules'), '@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'), module: path.resolve(__dirname, 'src', 'dummy.mjs'),
}, },
}, },
@@ -106,15 +108,8 @@ export default defineConfig({
mockReset: true, mockReset: true,
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/test-utils/setup-jest.ts'], setupFiles: ['src/test-utils/setup-jest.ts'],
alias: [
{
find: /^~\/(.*)/,
replacement: 'src/$1',
},
],
// workaround: sql.js is not ESModule friendly, yet... // workaround: sql.js is not ESModule friendly, yet...
deps: { deps: {
// inline: ['sql.js'],
optimizer: { optimizer: {
web: { web: {
include: ['sql.js'], include: ['sql.js'],