KWMv2/酷我 mflac 支持 #35
46
src/components/ImportSecretModal.tsx
Normal file
46
src/components/ImportSecretModal.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import {
|
||||
Center,
|
||||
Flex,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Tabs,
|
||||
Text,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { FileInput } from '~/components/FileInput';
|
||||
|
||||
export interface ImportSecretModalProps {
|
||||
clientName?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
onImport: (file: File) => void;
|
||||
}
|
||||
|
||||
export function ImportSecretModal({ clientName, children, show, onClose, onImport }: ImportSecretModalProps) {
|
||||
const handleFileReceived = (files: File[]) => onImport(files[0]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={show} onClose={onClose} closeOnOverlayClick={false} scrollBehavior="inside" size="xl">
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>导入密钥数据库</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<Flex as={ModalBody} gap={2} flexDir="column" flex={1}>
|
||||
<Center>
|
||||
<FileInput onReceiveFiles={handleFileReceived}>拖放或点我选择含有密钥的数据库文件</FileInput>
|
||||
</Center>
|
||||
|
||||
<Text mt={2}>选择你的{clientName && <>「{clientName}」</>}客户端平台以查看对应说明:</Text>
|
||||
<Flex as={Tabs} variant="enclosed" flexDir="column" flex={1} minH={0}>
|
||||
{children}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@ -26,9 +26,11 @@ import { useState } from 'react';
|
||||
import { MdExpandMore, MdMenu, MdOutlineSettingsBackupRestore } from 'react-icons/md';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { commitStagingChange, discardStagingChanges } from './settingsSlice';
|
||||
import { PanelKWMv2Key } from './panels/PanelKWMv2Key';
|
||||
|
||||
const TABS: { name: string; Tab: () => JSX.Element }[] = [
|
||||
{ name: 'QMCv2 密钥', Tab: PanelQMCv2Key },
|
||||
{ name: 'KWMv2 密钥', Tab: PanelKWMv2Key },
|
||||
{
|
||||
name: '其它/待定',
|
||||
Tab: () => <Text>这里空空如也~</Text>,
|
||||
|
67
src/features/settings/keyFormats.ts
Normal file
67
src/features/settings/keyFormats.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
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
|
||||
): S[] {
|
||||
const result: S[] = [];
|
||||
for (const [key, value] of Object.entries(src)) {
|
||||
const item = make(key, value as P[keyof P]);
|
||||
if (item) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
export function stagingKeyToProduction<S, P>(src: S[], toKey: (s: S) => keyof P, toValue: (s: S) => P[keyof P]): P {
|
||||
return objectify(src, toKey, toValue) as P;
|
||||
}
|
||||
|
||||
// QMCv2 (QQ)
|
||||
export interface StagingQMCv2Key {
|
||||
id: string;
|
||||
name: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export type ProductionQMCv2Keys = Record<string /* filename */, string /* ekey */>;
|
||||
|
||||
export const qmc2StagingToProductionKey = (key: StagingQMCv2Key) => key.name.normalize();
|
||||
export const qmc2StagingToProductionValue = (key: StagingQMCv2Key) => key.ekey.trim();
|
||||
export const qmc2ProductionToStaging = (
|
||||
key: keyof ProductionQMCv2Keys,
|
||||
value: ProductionQMCv2Keys[keyof ProductionQMCv2Keys]
|
||||
): StagingQMCv2Key => {
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: key.normalize(),
|
||||
ekey: value.trim(),
|
||||
};
|
||||
};
|
||||
|
||||
// KWMv2 (KuWo)
|
||||
|
||||
export interface StagingKWMv2Key {
|
||||
id: string;
|
||||
rid: string;
|
||||
quality: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export type ProductionKWMv2Keys = Record<string /* `${rid}-${quality}` */, string /* ekey */>;
|
||||
|
||||
export const kwm2StagingToProductionKey = (key: StagingKWMv2Key) => `${key.rid}-${key.quality}`;
|
||||
export const kwm2StagingToProductionValue = (key: StagingKWMv2Key) => key.ekey;
|
||||
export const kwm2ProductionToStaging = (
|
||||
key: keyof ProductionKWMv2Keys,
|
||||
value: ProductionKWMv2Keys[keyof ProductionKWMv2Keys]
|
||||
): null | StagingKWMv2Key => {
|
||||
if (typeof value !== 'string') return null;
|
||||
|
||||
const m = key.match(/^(\d+)-(\w+)$/);
|
||||
if (!m) return null;
|
||||
|
||||
const [_, rid, quality] = m;
|
||||
return { id: nanoid(), rid, quality, ekey: value };
|
||||
};
|
76
src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx
Normal file
76
src/features/settings/panels/KWMv2/KWMv2EKeyItem.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import {
|
||||
HStack,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
ListItem,
|
||||
Text,
|
||||
VStack,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdDelete, MdVpnKey } from 'react-icons/md';
|
||||
import { kwm2DeleteKey, kwm2UpdateKey } from '../../settingsSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { memo } from 'react';
|
||||
import { StagingKWMv2Key } from '../../keyFormats';
|
||||
|
||||
export const KWMv2EKeyItem = memo(({ id, ekey, quality, rid, i }: StagingKWMv2Key & { i: number }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateKey = (prop: keyof StagingKWMv2Key, e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(kwm2UpdateKey({ id, field: prop, value: e.target.value }));
|
||||
const deleteKey = () => dispatch(kwm2DeleteKey({ id }));
|
||||
|
||||
return (
|
||||
<ListItem mt={0} pt={2} pb={2} _even={{ bg: 'gray.50' }}>
|
||||
<HStack>
|
||||
<Text w="2em" textAlign="center">
|
||||
{i + 1}
|
||||
</Text>
|
||||
|
||||
<VStack flex={1}>
|
||||
<HStack flex={1} w="full">
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="资源 ID"
|
||||
value={rid}
|
||||
onChange={(e) => updateKey('rid', e)}
|
||||
type="number"
|
||||
maxW="8em"
|
||||
/>
|
||||
<Input
|
||||
variant="flushed"
|
||||
placeholder="音质格式"
|
||||
value={quality}
|
||||
onChange={(e) => updateKey('quality', e)}
|
||||
flex={1}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<InputGroup size="xs">
|
||||
<InputLeftElement pr="2">
|
||||
<Icon as={MdVpnKey} />
|
||||
</InputLeftElement>
|
||||
<Input variant="flushed" placeholder="密钥" value={ekey} onChange={(e) => updateKey('ekey', e)} />
|
||||
<InputRightElement>
|
||||
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
||||
<code>{ekey.length || '?'}</code>
|
||||
</Text>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
</VStack>
|
||||
|
||||
<IconButton
|
||||
aria-label="删除该密钥"
|
||||
icon={<Icon as={MdDelete} boxSize={6} />}
|
||||
variant="ghost"
|
||||
colorScheme="red"
|
||||
type="button"
|
||||
onClick={deleteKey}
|
||||
/>
|
||||
</HStack>
|
||||
</ListItem>
|
||||
);
|
||||
});
|
94
src/features/settings/panels/PanelKWMv2Key.tsx
Normal file
94
src/features/settings/panels/PanelKWMv2Key.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Code,
|
||||
Flex,
|
||||
HStack,
|
||||
Heading,
|
||||
Icon,
|
||||
IconButton,
|
||||
List,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Text,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { kwm2AddKey, kwm2ClearKeys } from '../settingsSlice';
|
||||
import { selectStagingKWMv2Keys } from '../settingsSelector';
|
||||
import { useState } from 'react';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
import { KWMv2EKeyItem } from './KWMv2/KWMv2EKeyItem';
|
||||
import { ImportSecretModal } from '~/components/ImportSecretModal';
|
||||
|
||||
export function PanelKWMv2Key() {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const kwm2Keys = useSelector(selectStagingKWMv2Keys);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
|
||||
const addKey = () => dispatch(kwm2AddKey());
|
||||
const clearAll = () => dispatch(kwm2ClearKeys());
|
||||
const handleSecretImport = () => {
|
||||
toast({
|
||||
title: '尚未实现',
|
||||
isClosable: true,
|
||||
status: 'error',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH={0} flexDir="column" flex={1}>
|
||||
<Heading as="h2" size="lg">
|
||||
酷我解密密钥(KwmV2)
|
||||
</Heading>
|
||||
|
||||
<Text>
|
||||
酷我安卓版本的「臻品音质」已经换用 V2 版,后缀名为 <Code>mflac</Code> 或沿用旧的 <Code>kwm</Code>。{''}
|
||||
该格式需要提取密钥后才能正常解密。
|
||||
</Text>
|
||||
|
||||
<HStack pb={2} pt={2}>
|
||||
<ButtonGroup isAttached colorScheme="purple" variant="outline">
|
||||
<Button onClick={addKey} leftIcon={<Icon as={MdAdd} />}>
|
||||
添加一条密钥
|
||||
</Button>
|
||||
<Menu>
|
||||
<MenuButton as={IconButton} icon={<MdExpandMore />}></MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem onClick={() => setShowImportModal(true)} icon={<Icon as={MdFileUpload} boxSize={5} />}>
|
||||
从文件导入密钥
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem color="red" onClick={clearAll} icon={<Icon as={MdDeleteForever} boxSize={5} />}>
|
||||
清空密钥
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</ButtonGroup>
|
||||
</HStack>
|
||||
|
||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||
<List spacing={3}>
|
||||
{kwm2Keys.map(({ id, ekey, quality, rid }, i) => (
|
||||
<KWMv2EKeyItem key={id} id={id} ekey={ekey} quality={quality} rid={rid} i={i} />
|
||||
))}
|
||||
</List>
|
||||
{kwm2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportSecretModal
|
||||
clientName="QQ 音乐"
|
||||
show={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={handleSecretImport}
|
||||
>
|
||||
尚未实现
|
||||
</ImportSecretModal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
@ -14,19 +14,33 @@ import {
|
||||
MenuDivider,
|
||||
MenuItem,
|
||||
MenuList,
|
||||
Tab,
|
||||
TabList,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Text,
|
||||
Tooltip,
|
||||
useToast,
|
||||
} from '@chakra-ui/react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys } from '../settingsSlice';
|
||||
import { qmc2AddKey, qmc2AllowFuzzyNameSearch, qmc2ClearKeys, qmc2ImportKeys } from '../settingsSlice';
|
||||
import { selectStagingQMCv2Settings } from '../settingsSelector';
|
||||
import React, { useState } from 'react';
|
||||
import { MdAdd, MdDeleteForever, MdExpandMore, MdFileUpload } from 'react-icons/md';
|
||||
import { ImportFileModal } from './QMCv2/ImportFileModal';
|
||||
import { KeyInput } from './QMCv2/KeyInput';
|
||||
import { QMCv2EKeyItem } from './QMCv2/QMCv2EKeyItem';
|
||||
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 { getFileName } from '~/util/pathHelper';
|
||||
import { InstructionsAndroid } from './QMCv2/InstructionsAndroid';
|
||||
import { InstructionsIOS } from './QMCv2/InstructionsIOS';
|
||||
import { InstructionsMac } from './QMCv2/InstructionsMac';
|
||||
import { InstructionsPC } from './QMCv2/InstructionsPC';
|
||||
|
||||
export function PanelQMCv2Key() {
|
||||
const toast = useToast();
|
||||
const dispatch = useDispatch();
|
||||
const { keys: qmc2Keys, allowFuzzyNameSearch } = useSelector(selectStagingQMCv2Settings);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
@ -38,6 +52,44 @@ export function PanelQMCv2Key() {
|
||||
dispatch(qmc2AllowFuzzyNameSearch({ enable: e.target.checked }));
|
||||
};
|
||||
|
||||
const handleSecretImport = async (file: File) => {
|
||||
try {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
|
||||
let qmc2Keys: null | Omit<StagingQMCv2Key, 'id'>[] = null;
|
||||
|
||||
if (/[_.]db$/i.test(file.name)) {
|
||||
const extractor = await DatabaseKeyExtractor.getInstance();
|
||||
qmc2Keys = extractor.extractQmAndroidDbKeys(fileBuffer);
|
||||
if (!qmc2Keys) {
|
||||
alert(`不是支持的 SQLite 数据库文件。\n表名:${qmc2Keys}`);
|
||||
return;
|
||||
}
|
||||
} else if (/MMKVStreamEncryptId|filenameEkeyMap/i.test(file.name)) {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const map = MMKVParser.toStringMap(new DataView(fileBuffer));
|
||||
qmc2Keys = Array.from(map.entries(), ([name, ekey]) => ({ name: getFileName(name), ekey }));
|
||||
}
|
||||
|
||||
if (qmc2Keys) {
|
||||
dispatch(qmc2ImportKeys(qmc2Keys));
|
||||
setShowImportModal(false);
|
||||
toast({
|
||||
title: `导入成功 (${qmc2Keys.length})`,
|
||||
description: '记得保存更改来应用。',
|
||||
isClosable: true,
|
||||
duration: 5000,
|
||||
status: 'success',
|
||||
});
|
||||
} else {
|
||||
alert(`不支持的文件:${file.name}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('error during import: ', e);
|
||||
alert(`导入数据库时发生错误:${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex minH={0} flexDir="column" flex={1}>
|
||||
<Heading as="h2" size="lg">
|
||||
@ -99,14 +151,40 @@ export function PanelQMCv2Key() {
|
||||
|
||||
<Box flex={1} minH={0} overflow="auto" pr="4">
|
||||
<List spacing={3}>
|
||||
{qmc2Keys.map(({ id, key, name }, i) => (
|
||||
<KeyInput key={id} id={id} ekey={key} name={name} i={i} />
|
||||
{qmc2Keys.map(({ id, ekey, name }, i) => (
|
||||
<QMCv2EKeyItem key={id} id={id} ekey={ekey} name={name} i={i} />
|
||||
))}
|
||||
</List>
|
||||
{qmc2Keys.length === 0 && <Text>还没有添加密钥。</Text>}
|
||||
</Box>
|
||||
|
||||
<ImportFileModal show={showImportModal} onClose={() => setShowImportModal(false)} />
|
||||
<ImportSecretModal
|
||||
clientName="QQ 音乐"
|
||||
show={showImportModal}
|
||||
onClose={() => setShowImportModal(false)}
|
||||
onImport={handleSecretImport}
|
||||
>
|
||||
<TabList>
|
||||
<Tab>安卓</Tab>
|
||||
<Tab>iOS</Tab>
|
||||
<Tab>Mac</Tab>
|
||||
<Tab>Windows</Tab>
|
||||
</TabList>
|
||||
<TabPanels flex={1} overflow="auto">
|
||||
<TabPanel>
|
||||
<InstructionsAndroid />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsIOS />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsMac />
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<InstructionsPC />
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</ImportSecretModal>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
@ -15,10 +15,10 @@ import { qmc2DeleteKey, qmc2UpdateKey } from '../../settingsSlice';
|
||||
import { useAppDispatch } from '~/hooks';
|
||||
import { memo } from 'react';
|
||||
|
||||
export const KeyInput = memo(({ id, name, ekey, i }: { id: string; name: string; ekey: string; i: number }) => {
|
||||
export const QMCv2EKeyItem = memo(({ id, name, ekey, i }: { id: string; name: string; ekey: string; i: number }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const updateKey = (prop: 'name' | 'key', e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
const updateKey = (prop: 'name' | 'ekey', e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
dispatch(qmc2UpdateKey({ id, field: prop, value: e.target.value }));
|
||||
const deleteKey = () => dispatch(qmc2DeleteKey({ id }));
|
||||
|
||||
@ -36,7 +36,7 @@ export const KeyInput = memo(({ id, name, ekey, i }: { id: string; name: string;
|
||||
<InputLeftElement pr="2">
|
||||
<Icon as={MdVpnKey} />
|
||||
</InputLeftElement>
|
||||
<Input variant="flushed" placeholder="密钥" value={ekey} onChange={(e) => updateKey('key', e)} />
|
||||
<Input variant="flushed" placeholder="密钥" value={ekey} onChange={(e) => updateKey('ekey', e)} />
|
||||
<InputRightElement>
|
||||
<Text pl="2" color={ekey.length ? 'green.500' : 'red.500'}>
|
||||
<code>{ekey.length || '?'}</code>
|
@ -6,6 +6,8 @@ import { hasOwn } from '~/util/objects';
|
||||
export const selectStagingQMCv2Settings = (state: RootState) => state.settings.staging.qmc2;
|
||||
export const selectFinalQMCv2Settings = (state: RootState) => state.settings.production.qmc2;
|
||||
|
||||
export const selectStagingKWMv2Keys = (state: RootState) => state.settings.staging.kwm2.keys;
|
||||
|
||||
export const selectDecryptOptionByFile = (state: RootState, name: string): DecryptCommandOptions => {
|
||||
const normalizedName = name.normalize();
|
||||
|
||||
|
@ -1,20 +1,39 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { objectify } from 'radash';
|
||||
import {
|
||||
ProductionKWMv2Keys,
|
||||
ProductionQMCv2Keys,
|
||||
StagingKWMv2Key,
|
||||
StagingQMCv2Key,
|
||||
kwm2ProductionToStaging,
|
||||
kwm2StagingToProductionKey,
|
||||
kwm2StagingToProductionValue,
|
||||
productionKeyToStaging,
|
||||
qmc2ProductionToStaging,
|
||||
qmc2StagingToProductionKey,
|
||||
qmc2StagingToProductionValue,
|
||||
stagingKeyToProduction,
|
||||
} from './keyFormats';
|
||||
|
||||
export interface StagingSettings {
|
||||
qmc2: {
|
||||
keys: { id: string; name: string; key: string }[];
|
||||
keys: StagingQMCv2Key[];
|
||||
allowFuzzyNameSearch: boolean;
|
||||
};
|
||||
kwm2: {
|
||||
keys: StagingKWMv2Key[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ProductionSettings {
|
||||
qmc2: {
|
||||
keys: Record<string, string>; // { [fileName]: ekey }
|
||||
keys: ProductionQMCv2Keys; // { [fileName]: ekey }
|
||||
allowFuzzyNameSearch: boolean;
|
||||
};
|
||||
kwm2: {
|
||||
keys: ProductionKWMv2Keys; // { [`${rid}-${quality}`]: ekey }
|
||||
};
|
||||
}
|
||||
|
||||
export interface SettingsState {
|
||||
@ -23,35 +42,33 @@ export interface SettingsState {
|
||||
}
|
||||
const initialState: SettingsState = {
|
||||
staging: {
|
||||
qmc2: {
|
||||
allowFuzzyNameSearch: false,
|
||||
keys: [],
|
||||
},
|
||||
qmc2: { allowFuzzyNameSearch: false, keys: [] },
|
||||
kwm2: { keys: [] },
|
||||
},
|
||||
production: {
|
||||
qmc2: {
|
||||
allowFuzzyNameSearch: false,
|
||||
keys: {},
|
||||
},
|
||||
qmc2: { allowFuzzyNameSearch: false, keys: {} },
|
||||
kwm2: { keys: {} },
|
||||
},
|
||||
};
|
||||
|
||||
const stagingToProduction = (staging: StagingSettings): ProductionSettings => ({
|
||||
qmc2: {
|
||||
keys: objectify(
|
||||
staging.qmc2.keys,
|
||||
(item) => item.name.normalize(),
|
||||
(item) => item.key.trim()
|
||||
),
|
||||
keys: stagingKeyToProduction(staging.qmc2.keys, qmc2StagingToProductionKey, qmc2StagingToProductionValue),
|
||||
allowFuzzyNameSearch: staging.qmc2.allowFuzzyNameSearch,
|
||||
},
|
||||
kwm2: {
|
||||
keys: stagingKeyToProduction(staging.kwm2.keys, kwm2StagingToProductionKey, kwm2StagingToProductionValue),
|
||||
},
|
||||
});
|
||||
|
||||
const productionToStaging = (production: ProductionSettings): StagingSettings => ({
|
||||
qmc2: {
|
||||
keys: Object.entries(production.qmc2.keys).map(([name, key]) => ({ id: nanoid(), name, key })),
|
||||
keys: productionKeyToStaging(production.qmc2.keys, qmc2ProductionToStaging),
|
||||
allowFuzzyNameSearch: production.qmc2.allowFuzzyNameSearch,
|
||||
},
|
||||
kwm2: {
|
||||
keys: productionKeyToStaging(production.kwm2.keys, kwm2ProductionToStaging),
|
||||
},
|
||||
});
|
||||
|
||||
export const settingsSlice = createSlice({
|
||||
@ -64,10 +81,11 @@ export const settingsSlice = createSlice({
|
||||
staging: productionToStaging(payload),
|
||||
};
|
||||
},
|
||||
//
|
||||
qmc2AddKey(state) {
|
||||
state.staging.qmc2.keys.push({ id: nanoid(), name: '', key: '' });
|
||||
state.staging.qmc2.keys.push({ id: nanoid(), name: '', ekey: '' });
|
||||
},
|
||||
qmc2ImportKeys(state, { payload }: PayloadAction<{ name: string; key: string }[]>) {
|
||||
qmc2ImportKeys(state, { payload }: PayloadAction<Omit<StagingQMCv2Key, 'id'>[]>) {
|
||||
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||
state.staging.qmc2.keys.push(...newItems);
|
||||
},
|
||||
@ -77,7 +95,7 @@ export const settingsSlice = createSlice({
|
||||
},
|
||||
qmc2UpdateKey(
|
||||
state,
|
||||
{ payload: { id, field, value } }: PayloadAction<{ id: string; field: 'name' | 'key'; 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) {
|
||||
@ -90,6 +108,31 @@ export const settingsSlice = createSlice({
|
||||
qmc2AllowFuzzyNameSearch(state, { payload: { enable } }: PayloadAction<{ enable: boolean }>) {
|
||||
state.staging.qmc2.allowFuzzyNameSearch = enable;
|
||||
},
|
||||
// TODO: reuse the logic somehow?
|
||||
kwm2AddKey(state) {
|
||||
state.staging.kwm2.keys.push({ id: nanoid(), ekey: '', quality: '', rid: '' });
|
||||
},
|
||||
kwm2ImportKeys(state, { payload }: PayloadAction<Omit<StagingKWMv2Key, 'id'>[]>) {
|
||||
const newItems = payload.map((item) => ({ id: nanoid(), ...item }));
|
||||
state.staging.kwm2.keys.push(...newItems);
|
||||
},
|
||||
kwm2DeleteKey(state, { payload: { id } }: PayloadAction<{ id: string }>) {
|
||||
const kwm2 = state.staging.kwm2;
|
||||
kwm2.keys = kwm2.keys.filter((item) => item.id !== id);
|
||||
},
|
||||
kwm2UpdateKey(
|
||||
state,
|
||||
{ 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) {
|
||||
keyItem[field] = value;
|
||||
}
|
||||
},
|
||||
kwm2ClearKeys(state) {
|
||||
state.staging.kwm2.keys = [];
|
||||
},
|
||||
//
|
||||
discardStagingChanges: (state) => {
|
||||
state.staging = productionToStaging(state.production);
|
||||
},
|
||||
@ -118,6 +161,12 @@ export const {
|
||||
qmc2ImportKeys,
|
||||
qmc2AllowFuzzyNameSearch,
|
||||
|
||||
kwm2AddKey,
|
||||
kwm2UpdateKey,
|
||||
kwm2DeleteKey,
|
||||
kwm2ClearKeys,
|
||||
kwm2ImportKeys,
|
||||
|
||||
commitStagingChange,
|
||||
discardStagingChanges,
|
||||
} = settingsSlice.actions;
|
||||
|
@ -3,7 +3,7 @@ import { SQLDatabase, SQLStatic, loadSQL } from './sqlite';
|
||||
|
||||
export interface QMAndroidKeyEntry {
|
||||
name: string;
|
||||
key: string;
|
||||
ekey: string;
|
||||
}
|
||||
|
||||
export class DatabaseKeyExtractor {
|
||||
@ -33,10 +33,10 @@ export class DatabaseKeyExtractor {
|
||||
}
|
||||
|
||||
const keys = db.exec('select file_path, ekey from `audio_file_ekey_table`')[0].values;
|
||||
return keys.map(([path, key]) => ({
|
||||
return keys.map(([path, ekey]) => ({
|
||||
// strip dir name
|
||||
name: getFileName(String(path)),
|
||||
key: String(key),
|
||||
ekey: String(ekey),
|
||||
}));
|
||||
} finally {
|
||||
db?.close();
|
||||
|
@ -95,4 +95,8 @@ export class MMKVParser {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static parseKuwoEKey(_view: DataView): unknown[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
10
src/util/__tests__/splitN.ts.test.ts
Normal file
10
src/util/__tests__/splitN.ts.test.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { splitN } from '../splitN';
|
||||
|
||||
test('some test cases', () => {
|
||||
expect(splitN('1,2,3', ',', 2)).toEqual(['1', '2,3']);
|
||||
expect(splitN('1,2,3', ',', 3)).toEqual(['1', '2', '3']);
|
||||
expect(splitN('1,2,3', ',', 4)).toEqual(['1', '2', '3']);
|
||||
|
||||
expect(splitN('1,2,3', '.', 3)).toEqual(['1,2,3']);
|
||||
expect(splitN('1,2,3', '?', 0)).toEqual(['1,2,3']);
|
||||
});
|
20
src/util/splitN.ts
Normal file
20
src/util/splitN.ts
Normal file
@ -0,0 +1,20 @@
|
||||
export function splitN(str: string, sep: string, maxN: number) {
|
||||
if (maxN <= 1) {
|
||||
return [str];
|
||||
}
|
||||
|
||||
const chunks: string[] = [];
|
||||
const lenSep = sep.length;
|
||||
let searchIdx = 0;
|
||||
for (; maxN > 1; maxN--) {
|
||||
const nextIdx = str.indexOf(sep, searchIdx);
|
||||
if (nextIdx === -1) {
|
||||
break;
|
||||
}
|
||||
chunks.push(str.slice(searchIdx, nextIdx));
|
||||
searchIdx = nextIdx + lenSep;
|
||||
}
|
||||
|
||||
chunks.push(str.slice(searchIdx));
|
||||
return chunks;
|
||||
}
|
Loading…
Reference in New Issue
Block a user