diff --git a/package.json b/package.json
index 94fe563..ec756d2 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,7 @@
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@reduxjs/toolkit": "^2.5.0",
- "@unlock-music/crypto": "0.1.2",
+ "@unlock-music/crypto": "0.1.6",
"framer-motion": "^11.14.4",
"nanoid": "^5.0.9",
"radash": "^12.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b9970d..c377efd 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,8 +39,8 @@ importers:
specifier: ^2.5.0
version: 2.5.0(react-redux@9.2.0(@types/react@18.3.16)(react@18.3.1)(redux@5.0.1))(react@18.3.1)
'@unlock-music/crypto':
- specifier: 0.1.2
- version: 0.1.2
+ specifier: 0.1.6
+ version: 0.1.6
framer-motion:
specifier: ^11.14.4
version: 11.14.4(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1491,8 +1491,8 @@ packages:
'@ungap/structured-clone@1.2.1':
resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==}
- '@unlock-music/crypto@0.1.2':
- resolution: {integrity: sha512-Ed72WZuQsuBvs3Cmo0fdlV1BllG81WK5wyDTFA5QThIZ2OkJVixi6jde6wbFJQZ6KBimC/e0Qk0ST1G8pyJcTg==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.1.2/crypto-0.1.2.tgz}
+ '@unlock-music/crypto@0.1.6':
+ resolution: {integrity: sha512-dFIL+25H0lrloGYZzm2ICNar0oGPCzwHDosK54au3rdbFI4XpD0wizOLs9K+huqspnnsXAxeaOQkamtd27fI2Q==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.1.6/crypto-0.1.6.tgz}
'@vitejs/plugin-react@4.3.4':
resolution: {integrity: sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==}
@@ -5156,7 +5156,7 @@ snapshots:
'@ungap/structured-clone@1.2.1': {}
- '@unlock-music/crypto@0.1.2': {}
+ '@unlock-music/crypto@0.1.6': {}
'@vitejs/plugin-react@4.3.4(vite@5.4.11(@types/node@22.10.2)(sass@1.83.0)(terser@5.37.0))':
dependencies:
diff --git a/src/components/AddKey.tsx b/src/components/AddKey.tsx
new file mode 100644
index 0000000..0088f77
--- /dev/null
+++ b/src/components/AddKey.tsx
@@ -0,0 +1,47 @@
+import {
+ Button,
+ ButtonGroup,
+ HStack,
+ Icon,
+ IconButton,
+ Menu,
+ MenuButton,
+ MenuDivider,
+ MenuItem,
+ MenuList,
+} from '@chakra-ui/react';
+import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
+
+export interface AddKeyProps {
+ addKey: () => void;
+ importKeyFromFile?: () => void;
+ clearKeys?: () => void;
+}
+
+export function AddKey({ addKey, importKeyFromFile, clearKeys }: AddKeyProps) {
+ return (
+
+
+ }>
+ 添加一条密钥
+
+
+
+
+ );
+}
diff --git a/src/components/ImportSecretModal.tsx b/src/components/ImportSecretModal.tsx
index cc099bc..d79932b 100644
--- a/src/components/ImportSecretModal.tsx
+++ b/src/components/ImportSecretModal.tsx
@@ -18,11 +18,19 @@ export interface ImportSecretModalProps {
children: React.ReactNode;
show: boolean;
onClose: () => void;
- onImport: (file: File) => void;
+ onImport: (file: File) => void|Promise;
}
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
- const handleFileReceived = (files: File[]) => onImport(files[0]);
+ const handleFileReceived = (files: File[]) => {
+ const promise = onImport(files[0]);
+ if (promise instanceof Promise) {
+ promise.catch(err => {
+ console.error('could not import: ', err);
+ });
+ }
+ return promise;
+ };
return (
diff --git a/src/decrypt-worker/constants.ts b/src/decrypt-worker/constants.ts
index 82cd52c..91c9e71 100644
--- a/src/decrypt-worker/constants.ts
+++ b/src/decrypt-worker/constants.ts
@@ -2,6 +2,7 @@ export enum DECRYPTION_WORKER_ACTION_NAME {
DECRYPT = 'DECRYPT',
FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME',
KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER',
+ KUGOU_PARSE_HEADER = 'KUGOU_PARSE_HEADER',
QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY',
VERSION = 'VERSION',
}
diff --git a/src/decrypt-worker/decipher/KugouMusic.ts b/src/decrypt-worker/decipher/KugouMusic.ts
index 567975f..f5cebcc 100644
--- a/src/decrypt-worker/decipher/KugouMusic.ts
+++ b/src/decrypt-worker/decipher/KugouMusic.ts
@@ -1,16 +1,18 @@
import { DecipherInstance, DecipherOK, DecipherResult, Status } from '~/decrypt-worker/Deciphers';
-import { KuGou } from '@unlock-music/crypto';
+import { KuGou, KuGouHeader } from '@unlock-music/crypto';
import type { DecryptCommandOptions } from '~/decrypt-worker/types.ts';
import { chunkBuffer } from '~/decrypt-worker/util/buffer.ts';
export class KugouMusicDecipher implements DecipherInstance {
cipherName = 'Kugou';
- async decrypt(buffer: Uint8Array, _options: DecryptCommandOptions): Promise {
+ async decrypt(buffer: Uint8Array, options: DecryptCommandOptions): Promise {
let kgm: KuGou | undefined;
+ let kgmHdr: KuGouHeader | undefined;
try {
- kgm = KuGou.from_header(buffer.subarray(0, 0x400));
+ kgmHdr = new KuGouHeader(buffer.subarray(0, 0x400));
+ kgm = KuGou.fromHeaderV5(kgmHdr, options.kugouKey);
const audioBuffer = new Uint8Array(buffer.subarray(0x400));
for (const [block, offset] of chunkBuffer(audioBuffer)) {
@@ -23,6 +25,7 @@ export class KugouMusicDecipher implements DecipherInstance {
data: audioBuffer,
};
} finally {
+ kgmHdr?.free();
kgm?.free();
}
}
diff --git a/src/decrypt-worker/types.ts b/src/decrypt-worker/types.ts
index a36f66c..17bbfc5 100644
--- a/src/decrypt-worker/types.ts
+++ b/src/decrypt-worker/types.ts
@@ -2,6 +2,7 @@ export interface DecryptCommandOptions {
fileName: string;
qmc2Key?: string;
kwm2key?: string;
+ kugouKey?: string;
qingTingAndroidKey?: string;
}
@@ -24,6 +25,15 @@ export type ParseKuwoHeaderResponse = null | {
qualityId: number;
};
+export interface ParseKugouHeaderPayload {
+ blobURI: string;
+}
+
+export type ParseKugouHeaderResponse = null | {
+ version: number;
+ audioHash: string;
+};
+
export interface GetQingTingFMDeviceKeyPayload {
product: string;
device: string;
diff --git a/src/decrypt-worker/worker.ts b/src/decrypt-worker/worker.ts
index c5862d7..b27560b 100644
--- a/src/decrypt-worker/worker.ts
+++ b/src/decrypt-worker/worker.ts
@@ -6,6 +6,7 @@ import { workerDecryptHandler } from './worker/decrypt.ts';
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
+import { workerParseKugouHeader } from '~/decrypt-worker/worker/kugou_parse_header.ts';
const bus = new WorkerServerBus();
onmessage = bus.onmessage;
@@ -14,4 +15,5 @@ bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler)
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
+bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER, workerParseKugouHeader);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);
diff --git a/src/decrypt-worker/worker/kugou_parse_header.ts b/src/decrypt-worker/worker/kugou_parse_header.ts
new file mode 100644
index 0000000..42d4a74
--- /dev/null
+++ b/src/decrypt-worker/worker/kugou_parse_header.ts
@@ -0,0 +1,23 @@
+import {
+ ParseKugouHeaderPayload, ParseKugouHeaderResponse,
+
+} from '~/decrypt-worker/types.ts';
+import { KuGouHeader } from '@unlock-music/crypto';
+
+export const workerParseKugouHeader = async ({ blobURI }: ParseKugouHeaderPayload): Promise => {
+ const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
+ const arrayBuffer = await blob.arrayBuffer();
+ const buffer = new Uint8Array(arrayBuffer.slice(0, 0x400));
+
+ let kwm : KuGouHeader | undefined;
+
+ try {
+ kwm = new KuGouHeader(buffer);
+ const { version, audioHash } = kwm;
+ return { version, audioHash };
+ } catch {
+ return null;
+ } finally {
+ kwm?.free();
+ }
+}
diff --git a/src/decrypt-worker/worker/kuwo_header_parse.ts b/src/decrypt-worker/worker/kuwo_header_parse.ts
index dc00439..b9fa3e4 100644
--- a/src/decrypt-worker/worker/kuwo_header_parse.ts
+++ b/src/decrypt-worker/worker/kuwo_header_parse.ts
@@ -1,7 +1,7 @@
-import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
+import { ParseKuwoHeaderPayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
import { KuwoHeader } from '@unlock-music/crypto';
-export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise => {
+export const workerParseKuwoHeader = async ({ blobURI }: ParseKuwoHeaderPayload): Promise => {
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
const arrayBuffer = await blob.arrayBuffer();
diff --git a/src/faq/KugouFAQ.tsx b/src/faq/KugouFAQ.tsx
new file mode 100644
index 0000000..75a4c31
--- /dev/null
+++ b/src/faq/KugouFAQ.tsx
@@ -0,0 +1,31 @@
+import { Alert, AlertIcon, Container, Flex, List, ListItem, Text } from '@chakra-ui/react';
+import { Header4 } from '~/components/HelpText/Headers';
+import { SegmentKeyImportInstructions } from './SegmentKeyImportInstructions';
+import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
+
+export function KugouFAQ() {
+ return (
+ <>
+ 解锁失败
+
+
+
+ 酷狗现在对部分用户推送了 kgg
加密格式(安卓、Windows 客户端)。
+
+ 根据平台不同,你需要提取密钥数据库。
+
+
+
+
+
+ 安卓用户提取密钥需要 root 权限,或注入文件提供器。
+
+
+
+
+ } />
+
+
+ >
+ );
+}
diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts
index 54cb9f4..02a792e 100644
--- a/src/features/file-listing/fileListingSlice.ts
+++ b/src/features/file-listing/fileListingSlice.ts
@@ -5,13 +5,18 @@ import type { RootState } from '~/store';
import { DECRYPTION_WORKER_ACTION_NAME, type DecryptionResult } from '~/decrypt-worker/constants';
import type {
DecryptCommandOptions,
- FetchMusicExNamePayload,
+ FetchMusicExNamePayload, ParseKugouHeaderPayload, ParseKugouHeaderResponse,
ParseKuwoHeaderPayload,
- ParseKuwoHeaderResponse,
+ ParseKuwoHeaderResponse
} from '~/decrypt-worker/types';
import { decryptionQueue, workerClientBus } from '~/decrypt-worker/client';
import { DecryptErrorType } from '~/decrypt-worker/util/DecryptError';
-import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector';
+import {
+ selectKugouKey,
+ selectKWMv2Key,
+ selectQMCv2KeyByFileName,
+ selectQtfmAndroidKey
+} from '../settings/settingsSelector';
export enum ProcessState {
QUEUED = 'QUEUED',
@@ -70,7 +75,7 @@ export const processFile = createAsyncThunk<
thunkAPI.dispatch(setFileAsProcessing({ id: fileId }));
};
- const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([
+ const [qmcv2MusicExMediaFile, kuwoHdr, kugouHdr] = await Promise.all([
workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, {
blobURI: file.raw,
}),
@@ -78,12 +83,17 @@ export const processFile = createAsyncThunk<
DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER,
{ blobURI: file.raw },
),
+ workerClientBus.request(
+ DECRYPTION_WORKER_ACTION_NAME.KUGOU_PARSE_HEADER,
+ { blobURI: file.raw },
+ ),
]);
const options: DecryptCommandOptions = {
fileName: file.fileName,
qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName),
kwm2key: selectKWMv2Key(state, kuwoHdr),
+ kugouKey: selectKugouKey(state, kugouHdr),
qingTingAndroidKey: selectQtfmAndroidKey(state),
};
return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess);
diff --git a/src/features/settings/Settings.tsx b/src/features/settings/Settings.tsx
index 7209143..ecbded8 100644
--- a/src/features/settings/Settings.tsx
+++ b/src/features/settings/Settings.tsx
@@ -31,10 +31,12 @@ import { commitStagingChange, discardStagingChanges } from './settingsSlice';
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
import { selectIsSettingsNotSaved } from './settingsSelector';
import { PanelQingTing } from './panels/PanelQingTing';
+import { PanelKGGKey } from '~/features/settings/panels/PanelKGGKey.tsx';
const TABS: { name: string; Tab: () => JSX.Element }[] = [
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
+ { name: 'KGG 密钥', Tab: PanelKGGKey },
{ name: '蜻蜓 FM', Tab: PanelQingTing },
{
name: '其它/待定',
diff --git a/src/features/settings/keyFormats.ts b/src/features/settings/keyFormats.ts
index 17df923..559e3c9 100644
--- a/src/features/settings/keyFormats.ts
+++ b/src/features/settings/keyFormats.ts
@@ -14,6 +14,7 @@ export function productionKeyToStaging>(
}
return result;
}
+
export function stagingKeyToProduction(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P {
return objectify(src, toKey, toValue) as P;
}
@@ -41,7 +42,6 @@ export const qmc2ProductionToStaging = (
};
// KWMv2 (KuWo)
-
export interface StagingKWMv2Key {
id: string;
/**
@@ -64,7 +64,7 @@ export const parseKwm2ProductionKey = (key: string): null | { rid: string; quali
return { rid, quality };
};
-export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/[\D]/g, '')}`;
+export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality.replace(/\D/g, '')}`;
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
export const kwm2ProductionToStaging = (
key: keyof ProductionKWMv2Keys,
@@ -78,3 +78,21 @@ export const kwm2ProductionToStaging = (
return { id: nanoid(), rid, quality, ekey: value };
};
+
+// KuGou (kgg, kgm v5)
+export interface StagingKugouKey {
+ id: string;
+ audioHash: string;
+ ekey: string;
+}
+
+export type ProductionKugouKey = Record;
+export const kugouStagingToProductionKey = (key: StagingKugouKey) => key.audioHash.normalize();
+export const kugouStagingToProductionValue = (key: StagingKugouKey) => key.ekey.normalize();
+export const kugouProductionToStaging = (
+ key: keyof ProductionKugouKey,
+ value: ProductionKugouKey[keyof ProductionKugouKey],
+): null | StagingKugouKey => {
+ if (typeof value !== 'string') return null;
+ return { id: nanoid(), audioHash: key.normalize(), ekey: value };
+};
diff --git a/src/features/settings/panels/Kugou/InstructionsPC.tsx b/src/features/settings/panels/Kugou/InstructionsPC.tsx
new file mode 100644
index 0000000..7e30e9f
--- /dev/null
+++ b/src/features/settings/panels/Kugou/InstructionsPC.tsx
@@ -0,0 +1,34 @@
+import { Code, Heading, ListItem, OrderedList, Text } from '@chakra-ui/react';
+import { FilePathBlock } from '~/components/FilePathBlock.tsx';
+
+export function InstructionsPC() {
+ return (
+ <>
+ 酷狗的 Windows 客户端使用 SQLite 数据库储存密钥。
+ 该密钥文件通常存储在下述路径:
+ %APPDATA%\KuGou8\KGMusicV3.db
+
+
+ 导入密钥
+
+
+
+
+ 选中并复制上述的 KGMusicV3.db
文件路径
+
+
+
+ 点击上方的「文件选择区域」,打开「文件选择框」
+
+
+
+ 在「文件名」输入框中粘贴之前复制的 KGMusicV3.db
文件路径
+
+
+
+ 按下「回车键」确认。
+
+
+ >
+ );
+}
diff --git a/src/features/settings/panels/Kugou/KugouAllInstructions.tsx b/src/features/settings/panels/Kugou/KugouAllInstructions.tsx
new file mode 100644
index 0000000..bb25b70
--- /dev/null
+++ b/src/features/settings/panels/Kugou/KugouAllInstructions.tsx
@@ -0,0 +1,25 @@
+import { Tab, TabList, TabPanel, TabPanels } from '@chakra-ui/react';
+import { AndroidADBPullInstruction } from '~/components/AndroidADBPullInstruction/AndroidADBPullInstruction';
+import { InstructionsPC } from './InstructionsPC';
+
+export function KugouAllInstructions() {
+ return (
+ <>
+
+ 安卓
+ Windows
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/features/settings/panels/Kugou/KugouEKeyItem.tsx b/src/features/settings/panels/Kugou/KugouEKeyItem.tsx
new file mode 100644
index 0000000..e5eba16
--- /dev/null
+++ b/src/features/settings/panels/Kugou/KugouEKeyItem.tsx
@@ -0,0 +1,72 @@
+import {
+ HStack,
+ Icon,
+ IconButton,
+ Input,
+ InputGroup,
+ InputLeftElement,
+ InputRightElement,
+ ListItem,
+ Text,
+ VStack,
+} from '@chakra-ui/react';
+import { MdDelete, MdVpnKey } from 'react-icons/md';
+import { kugouDeleteKey, kugouUpdateKey } from '../../settingsSlice';
+import { useAppDispatch } from '~/hooks';
+import { memo } from 'react';
+import { StagingKugouKey } from '../../keyFormats';
+
+export const KugouEKeyItem = memo(({ id, ekey, audioHash, i }: StagingKugouKey & { i: number }) => {
+ const dispatch = useAppDispatch();
+
+ const updateKey = (prop: keyof StagingKugouKey, e: React.ChangeEvent) =>
+ dispatch(kugouUpdateKey({ id, field: prop, value: e.target.value }));
+ const deleteKey = () => dispatch(kugouDeleteKey({ id }));
+
+ return (
+
+
+
+ {i + 1}
+
+
+
+
+ updateKey('audioHash', e)}
+ />
+
+
+
+
+
+
+ updateKey('ekey', e)}
+ />
+
+
+ {ekey.length || '?'}
+
+
+
+
+
+ }
+ variant="ghost"
+ colorScheme="red"
+ type="button"
+ onClick={deleteKey}
+ />
+
+
+ );
+});
diff --git a/src/features/settings/panels/PanelKGGKey.tsx b/src/features/settings/panels/PanelKGGKey.tsx
new file mode 100644
index 0000000..7db3962
--- /dev/null
+++ b/src/features/settings/panels/PanelKGGKey.tsx
@@ -0,0 +1,87 @@
+import { Box, Flex, Heading, List, Text, useToast } from '@chakra-ui/react';
+import { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+
+import { ImportSecretModal } from '~/components/ImportSecretModal';
+
+import { kugouAddKey, kugouClearKeys, kugouImportKeys } from '../settingsSlice';
+import { selectStagingKugouV5Keys } from '../settingsSelector';
+import type { StagingKugouKey } from '../keyFormats';
+import { AddKey } from '~/components/AddKey.tsx';
+import { KugouEKeyItem } from '~/features/settings/panels/Kugou/KugouEKeyItem.tsx';
+import { KugouAllInstructions } from '~/features/settings/panels/Kugou/KugouAllInstructions.tsx';
+import { parseAndroidKugouMMKV } from '~/util/mmkv/kugou.ts';
+import { DatabaseKeyExtractor } from '~/util/DatabaseKeyExtractor.ts';
+
+export function PanelKGGKey() {
+ const toast = useToast();
+ const dispatch = useDispatch();
+ const kugouKeys = useSelector(selectStagingKugouV5Keys);
+ const [showImportModal, setShowImportModal] = useState(false);
+
+ const addKey = () => dispatch(kugouAddKey());
+ const clearAll = () => dispatch(kugouClearKeys());
+ const handleSecretImport = async (file: File) => {
+ let keys: Omit[] | null = null;
+ if (/mggkey_multi_process/i.test(file.name)) {
+ keys = parseAndroidKugouMMKV(new DataView(await file.arrayBuffer()));
+ } else if (/^KGMusicV3\.db$/.test(file.name)) {
+ const extractor = await DatabaseKeyExtractor.getInstance();
+ keys = extractor.extractKugouKeyFromEncryptedDb(await file.arrayBuffer());
+ }
+
+ if (keys?.length === 0) {
+ toast({
+ title: '未导入密钥',
+ description: '选择的密钥数据库文件未发现任何可用的密钥。',
+ isClosable: true,
+ status: 'warning',
+ });
+ } else if (keys) {
+ dispatch(kugouImportKeys(keys));
+ setShowImportModal(false);
+ toast({
+ title: `导入完成,共导入了 ${keys.length} 个密钥。`,
+ description: '记得按下「保存」来应用。',
+ isClosable: true,
+ status: 'success',
+ });
+ } else {
+ toast({
+ title: `不支持的文件:${file.name}`,
+ isClosable: true,
+ status: 'error',
+ });
+ }
+ };
+
+ return (
+
+
+ 酷狗解密密钥 (KGG / KGM v5)
+
+
+ 酷狗已经升级了加密方式,现在使用 KGG / KGM v5 加密。
+
+ setShowImportModal(true)} clearKeys={clearAll} />
+
+
+
+ {kugouKeys.map(({ id, audioHash, ekey }, i) => (
+
+ ))}
+
+ {kugouKeys.length === 0 && 还没有添加密钥。}
+
+
+ setShowImportModal(false)}
+ onImport={handleSecretImport}
+ >
+
+
+
+ );
+}
diff --git a/src/features/settings/persistSettings.ts b/src/features/settings/persistSettings.ts
index 17b548b..21f4fc7 100644
--- a/src/features/settings/persistSettings.ts
+++ b/src/features/settings/persistSettings.ts
@@ -34,6 +34,16 @@ function mergeSettings(settings: ProductionSettings): ProductionSettings {
}
}
+ if (settings?.kugou) {
+ const { keys } = settings.kugou;
+
+ for (const [k, v] of enumObject(keys)) {
+ if (typeof v === 'string') {
+ draft.kugou.keys[k] = v;
+ }
+ }
+ }
+
if (typeof settings?.qtfm?.android === 'string') {
draft.qtfm.android = settings.qtfm.android.replace(/[^0-9a-fA-F]/g, '');
}
diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts
index 4d37903..991ba3e 100644
--- a/src/features/settings/settingsSelector.ts
+++ b/src/features/settings/settingsSelector.ts
@@ -2,7 +2,7 @@ import type { RootState } from '~/store';
import { closestByLevenshtein } from '~/util/levenshtein';
import { hasOwn } from '~/util/objects';
import { kwm2StagingToProductionKey } from './keyFormats';
-import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
+import type { ParseKugouHeaderResponse, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
@@ -12,6 +12,9 @@ export const selectFinalQMCv2Settings = (state: RootState) => state.settings.pro
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
export const selectFinalKWMv2Keys = (state: RootState) => state.settings.production.kwm2.keys;
+export const selectStagingKugouV5Keys = (state: RootState) => state.settings.staging.kugou.keys;
+export const selectFinalKugouV5Keys = (state: RootState) => state.settings.production.kugou.keys;
+
export const selectQMCv2KeyByFileName = (state: RootState, name: string): string | undefined => {
const normalizedName = name.normalize();
@@ -50,5 +53,16 @@ export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse):
return ekey;
};
+export const selectKugouKey = (state: RootState, hdr: ParseKugouHeaderResponse): string | undefined => {
+ if (!hdr) {
+ return;
+ }
+
+ const keys = selectFinalKugouV5Keys(state);
+ const lookupKey = hdr.audioHash;
+
+ return hasOwn(keys, lookupKey) ? keys[lookupKey] : undefined;
+};
+
export const selectStagingQtfmAndroidKey = (state: RootState) => state.settings.staging.qtfm.android;
export const selectQtfmAndroidKey = (state: RootState) => state.settings.production.qtfm.android;
diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts
index ce9325f..852e969 100644
--- a/src/features/settings/settingsSlice.ts
+++ b/src/features/settings/settingsSlice.ts
@@ -14,6 +14,11 @@ import {
qmc2StagingToProductionKey,
qmc2StagingToProductionValue,
stagingKeyToProduction,
+ ProductionKugouKey,
+ kugouProductionToStaging,
+ kugouStagingToProductionKey,
+ kugouStagingToProductionValue,
+ StagingKugouKey,
} from './keyFormats';
export interface StagingSettings {
@@ -24,6 +29,9 @@ export interface StagingSettings {
kwm2: {
keys: StagingKWMv2Key[];
};
+ kugou: {
+ keys: StagingKugouKey[];
+ };
qtfm: {
android: string;
};
@@ -37,6 +45,9 @@ export interface ProductionSettings {
kwm2: {
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
};
+ kugou: {
+ keys: ProductionKugouKey; // { [fileName]: ekey }
+ };
qtfm: {
android: string;
};
@@ -47,16 +58,19 @@ export interface SettingsState {
staging: StagingSettings;
production: ProductionSettings;
}
+
const initialState: SettingsState = {
dirty: false,
staging: {
qmc2: { allowFuzzyNameSearch: true, keys: [] },
kwm2: { keys: [] },
qtfm: { android: '' },
+ kugou: { keys: [] },
},
production: {
qmc2: { allowFuzzyNameSearch: true, keys: {} },
kwm2: { keys: {} },
+ kugou: { keys: {} },
qtfm: { android: '' },
},
};
@@ -69,6 +83,9 @@ const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
kwm2: {
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
},
+ kugou: {
+ keys: stagingKeyToProduction(staging.kugou.keys, kugouStagingToProductionKey, kugouStagingToProductionValue),
+ },
qtfm: staging.qtfm,
});
@@ -80,6 +97,9 @@ const productionToStaging = (production: ProductionSettings): StagingSettings =>
kwm2: {
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
},
+ kugou: {
+ keys: productionKeyToStaging(production.kugou.keys, kugouProductionToStaging),
+ },
qtfm: production.qtfm,
});
@@ -152,14 +172,42 @@ export const settingsSlice = createSlice({
state.dirty = true;
}
},
- qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
- state.staging.qtfm.android = deviceKey;
- state.dirty = true;
- },
kwm2ClearKeys(state) {
state.staging.kwm2.keys = [];
state.dirty = true;
},
+ kugouAddKey(state) {
+ state.staging.kugou.keys.push({ id: nanoid(), audioHash: '', ekey: '' });
+ state.dirty = true;
+ },
+ kugouImportKeys(state, { payload }: PayloadAction[]>) {
+ const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
+ state.staging.kugou.keys.push(...newItems);
+ state.dirty = true;
+ },
+ kugouDeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
+ const kugou = state.staging.kugou;
+ kugou.keys = kugou.keys.filter((item) => item.id !== id);
+ state.dirty = true;
+ },
+ kugouUpdateKey(
+ state,
+ { payload: { id, field, value } }: PayloadAction<{ id: string; field: keyof StagingKugouKey; value: string }>,
+ ) {
+ const keyItem = state.staging.kugou.keys.find((item) => item.id === id);
+ if (keyItem) {
+ keyItem[field] = value;
+ state.dirty = true;
+ }
+ },
+ kugouClearKeys(state) {
+ state.staging.kugou.keys = [];
+ state.dirty = true;
+ },
+ qtfmAndroidUpdateKey(state, { payload: { deviceKey } }: PayloadAction<{ deviceKey: string }>) {
+ state.staging.qtfm.android = deviceKey;
+ state.dirty = true;
+ },
//
discardStagingChanges: (state) => {
state.dirty = false;
@@ -197,6 +245,12 @@ export const {
kwm2ClearKeys,
kwm2ImportKeys,
+ kugouAddKey,
+ kugouUpdateKey,
+ kugouDeleteKey,
+ kugouClearKeys,
+ kugouImportKeys,
+
qtfmAndroidUpdateKey,
commitStagingChange,
diff --git a/src/tabs/FaqTab.tsx b/src/tabs/FaqTab.tsx
index 1aec5ff..ae6834c 100644
--- a/src/tabs/FaqTab.tsx
+++ b/src/tabs/FaqTab.tsx
@@ -1,8 +1,23 @@
+import { FC, Fragment } from 'react';
import { Center, Container, Heading, Link, ListItem, UnorderedList } from '@chakra-ui/react';
import { Header3 } from '~/components/HelpText/Headers';
import { KuwoFAQ } from '~/faq/KuwoFAQ';
import { OtherFAQ } from '~/faq/OtherFAQ';
import { QQMusicFAQ } from '~/faq/QQMusicFAQ';
+import { KugouFAQ } from '~/faq/KugouFAQ.tsx';
+
+type FAQEntry = {
+ id: string;
+ title: string;
+ Help: FC;
+};
+
+const faqEntries: FAQEntry[] = [
+ { id: 'qqmusic', title: 'QQ 音乐', Help: QQMusicFAQ },
+ { id: 'kuwo', title: '酷我音乐', Help: KuwoFAQ },
+ { id: 'kugou', title: '酷狗音乐', Help: KugouFAQ },
+ { id: 'other', title: '其它问题', Help: OtherFAQ },
+];
export function FaqTab() {
return (
@@ -12,22 +27,18 @@ export function FaqTab() {
答疑目录
-
- QQ 音乐
-
-
- 酷我音乐
-
-
- 其它问题
-
+ {faqEntries.map(({ id, title }) => (
+
+ {title}
+
+ ))}
- QQ 音乐
-
- 酷我音乐
-
- 其它问题
-
+ {faqEntries.map(({ id, title, Help }) => (
+
+ {title}
+
+
+ ))}
);
}
diff --git a/src/util/DatabaseKeyExtractor.ts b/src/util/DatabaseKeyExtractor.ts
index c749463..2caac6b 100644
--- a/src/util/DatabaseKeyExtractor.ts
+++ b/src/util/DatabaseKeyExtractor.ts
@@ -1,11 +1,17 @@
import { getFileName } from './pathHelper';
import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
+import { KuGou } from '@unlock-music/crypto';
export interface QMAndroidKeyEntry {
name: string;
ekey: string;
}
+export type KugouKeyEntry = {
+ audioHash: string;
+ ekey: string;
+};
+
export class DatabaseKeyExtractor {
private static _instance: DatabaseKeyExtractor;
@@ -52,4 +58,34 @@ export class DatabaseKeyExtractor {
db?.close();
}
}
+
+ extractKugouKeyFromEncryptedDb(buffer: ArrayBuffer): null | KugouKeyEntry[] {
+ const dbBuffer = new Uint8Array(buffer);
+ let db: SQLDatabase | null = null;
+
+ try {
+ KuGou.decryptDatabase(dbBuffer);
+ db = new this.SQL.Database(dbBuffer);
+
+ let sql: undefined | string;
+ if (this.hasTable(db, 'ShareFileItems')) {
+ sql = `select EncryptionKeyId, EncryptionKey from ShareFileItems where EncryptionKey != '' group by EncryptionKeyId`;
+ }
+ if (!sql) return null;
+
+ const result = db.exec(sql);
+ if (result.length === 0) {
+ return [];
+ }
+
+ const keys = result[0].values;
+ return keys.map(([audioHash, ekey]) => ({
+ // strip dir name
+ audioHash: String(audioHash).normalize(),
+ ekey: String(ekey).normalize(),
+ }));
+ } finally {
+ db?.close();
+ }
+ }
}
diff --git a/src/util/mmkv/kugou.ts b/src/util/mmkv/kugou.ts
new file mode 100644
index 0000000..a58cbaf
--- /dev/null
+++ b/src/util/mmkv/kugou.ts
@@ -0,0 +1,16 @@
+import type { StagingKugouKey } from '~/features/settings/keyFormats';
+import { MMKVParser } from '../MMKVParser';
+
+export function parseAndroidKugouMMKV(view: DataView): Omit[] {
+ const mmkv = new MMKVParser(view);
+ const result: Omit[] = [];
+ while (!mmkv.eof) {
+ const audioHash = mmkv.readString();
+ const ekey = mmkv.readStringValue();
+
+ if (audioHash.length === 0x20 && ekey) {
+ result.push({ audioHash, ekey });
+ }
+ }
+ return result;
+}