Compare commits

...

2 Commits

12 changed files with 70 additions and 82 deletions

View File

@ -23,7 +23,7 @@
"@emotion/react": "^11.11.1", "@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.11.0",
"@reduxjs/toolkit": "^2.0.1", "@reduxjs/toolkit": "^2.0.1",
"@unlock-music/crypto": "0.0.0-alpha.16", "@unlock-music/crypto": "0.0.0-alpha.18",
"framer-motion": "^10.16.16", "framer-motion": "^10.16.16",
"nanoid": "^5.0.4", "nanoid": "^5.0.4",
"radash": "^11.0.0", "radash": "^11.0.0",

View File

@ -39,8 +39,8 @@ importers:
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1(react-redux@9.0.4(@types/react@18.2.45)(react@18.2.0)(redux@5.0.0))(react@18.2.0) version: 2.0.1(react-redux@9.0.4(@types/react@18.2.45)(react@18.2.0)(redux@5.0.0))(react@18.2.0)
'@unlock-music/crypto': '@unlock-music/crypto':
specifier: 0.0.0-alpha.16 specifier: 0.0.0-alpha.18
version: 0.0.0-alpha.16 version: 0.0.0-alpha.18
framer-motion: framer-motion:
specifier: ^10.16.16 specifier: ^10.16.16
version: 10.16.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0) version: 10.16.16(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@ -1921,8 +1921,8 @@ packages:
'@ungap/structured-clone@1.2.0': '@ungap/structured-clone@1.2.0':
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
'@unlock-music/crypto@0.0.0-alpha.16': '@unlock-music/crypto@0.0.0-alpha.18':
resolution: {integrity: sha512-QDhDWTsZOOkSQ64d3+pBwPCfesit6MiO63SzV7TQB8rQ/lPb78MMz6C4ECIjP9ijCBXds4PT4CBXp8vJDifAEg==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.0.0-alpha.16/crypto-0.0.0-alpha.16.tgz} resolution: {integrity: sha512-/2VgU15WCW+GnWOgYd18GylQTkCGgAW7cf/qBcjXWU+2qtsvv9heiN8LkIWWz+NMbO0XDRONXijAKdKYf38o/g==, tarball: https://git.unlock-music.dev/api/packages/um/npm/%40unlock-music%2Fcrypto/-/0.0.0-alpha.18/crypto-0.0.0-alpha.18.tgz}
'@vitejs/plugin-react@4.2.1': '@vitejs/plugin-react@4.2.1':
resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==}
@ -6110,7 +6110,7 @@ snapshots:
'@ungap/structured-clone@1.2.0': {} '@ungap/structured-clone@1.2.0': {}
'@unlock-music/crypto@0.0.0-alpha.16': {} '@unlock-music/crypto@0.0.0-alpha.18': {}
'@vitejs/plugin-react@4.2.1(vite@5.0.10(@types/node@20.10.5)(sass@1.69.5)(terser@5.27.0))': '@vitejs/plugin-react@4.2.1(vite@5.0.10(@types/node@20.10.5)(sass@1.69.5)(terser@5.27.0))':
dependencies: dependencies:

View File

@ -1,27 +0,0 @@
import { bytesToUTF8String } from '~/decrypt-worker/util/utf8Encoder';
import { strlen } from './strlen';
export interface KuwoHeader {
rid: string; // uint64
encVersion: 1 | 2; // uint32
quality: string;
}
const KUWO_MAGIC_HDRS = new Set(['yeelion-kuwo\x00\x00\x00\x00', 'yeelion-kuwo-tme']);
export function parseKuwoHeader(view: DataView): KuwoHeader | null {
const magic = view.buffer.slice(view.byteOffset, view.byteOffset + 0x10);
if (!KUWO_MAGIC_HDRS.has(bytesToUTF8String(magic))) {
return null; // not kuwo-encrypted file
}
const qualityBytes = new Uint8Array(view.buffer.slice(view.byteOffset + 0x30, view.byteOffset + 0x40));
const qualityLen = strlen(qualityBytes);
const quality = bytesToUTF8String(qualityBytes.slice(0, qualityLen));
return {
encVersion: view.getUint32(0x10, true) as 1 | 2,
rid: view.getUint32(0x18, true).toString(),
quality,
};
}

View File

@ -1,9 +0,0 @@
export function strlen(data: Uint8Array): number {
const n = data.byteLength;
for (let i = 0; i < n; i++) {
if (data[i] === 0) {
return i;
}
}
return n;
}

View File

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

View File

@ -12,10 +12,18 @@ export interface DecryptCommandPayload {
} }
export interface FetchMusicExNamePayload { export interface FetchMusicExNamePayload {
id: string;
blobURI: string; blobURI: string;
} }
export interface ParseKuwoHeaderPayload {
blobURI: string;
}
export type ParseKuwoHeaderResponse = null | {
resourceId: number;
qualityId: number;
};
export interface GetQingTingFMDeviceKeyPayload { export interface GetQingTingFMDeviceKeyPayload {
product: string; product: string;
device: string; device: string;

View File

@ -5,6 +5,7 @@ import { getUmcVersion } from '@unlock-music/crypto';
import { workerDecryptHandler } from './worker/decrypt.ts'; import { workerDecryptHandler } from './worker/decrypt.ts';
import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts'; import { workerParseMusicExMediaName } from './worker/qmcv2_parser.ts';
import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts'; import { workerGetQtfmDeviceKey } from '~/decrypt-worker/worker/qtfm_device_key.ts';
import { workerParseKuwoHeader } from '~/decrypt-worker/worker/kuwo_header_parse.ts';
const bus = new WorkerServerBus(); const bus = new WorkerServerBus();
onmessage = bus.onmessage; onmessage = bus.onmessage;
@ -12,4 +13,5 @@ onmessage = bus.onmessage;
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler); 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.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader);
bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey);

View File

@ -4,7 +4,7 @@ import { allCryptoFactories } from '../Deciphers.ts';
import { toBlob } from '~/decrypt-worker/util/buffer.ts'; import { toBlob } from '~/decrypt-worker/util/buffer.ts';
import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deciphers.ts'; import { DecipherFactory, DecipherInstance, Status } from '~/decrypt-worker/Deciphers.ts';
import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts'; import { UnsupportedSourceFile } from '~/decrypt-worker/util/DecryptError.ts';
import { ready as umCryptoReady } from '../../../../lib_um_crypto_rust/um_wasm_loader'; import { ready as umCryptoReady } from '@unlock-music/crypto';
import { go } from '~/util/go.ts'; import { go } from '~/util/go.ts';
import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts'; import { detectAudioExtension } from '~/decrypt-worker/util/audioType.ts';

View File

@ -0,0 +1,17 @@
import { FetchMusicExNamePayload, ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
import { KuwoHeader } from '@unlock-music/crypto';
export const workerParseKuwoHeader = async ({ blobURI }: FetchMusicExNamePayload): Promise<ParseKuwoHeaderResponse> => {
const blob = await fetch(blobURI, { headers: { Range: 'bytes=0-1023' } }).then((r) => r.blob());
const arrayBuffer = await blob.arrayBuffer();
try {
const buffer = new Uint8Array(arrayBuffer.slice(0, 1024));
const kwm = KuwoHeader.parse(buffer);
const { qualityId, resourceId } = kwm;
kwm.free();
return { qualityId, resourceId };
} catch {
return null;
}
};

View File

@ -1,19 +1,15 @@
import type { FetchMusicExNamePayload } from '~/decrypt-worker/types.ts'; import type { FetchMusicExNamePayload } from '~/decrypt-worker/types.ts';
import { timedLogger } from '~/util/logUtils.ts';
import { QMCFooter } from '@unlock-music/crypto'; import { QMCFooter } from '@unlock-music/crypto';
export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => { export const workerParseMusicExMediaName = async ({ blobURI }: FetchMusicExNamePayload) => {
const label = `qmcMusixExDetectName(${id.replace('://', ':')})`; const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob());
return timedLogger(label, async () => { const arrayBuffer = await blob.arrayBuffer();
const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob());
const arrayBuffer = await blob.arrayBuffer();
try { try {
const buffer = new Uint8Array(arrayBuffer.slice(-1024)); const buffer = new Uint8Array(arrayBuffer.slice(-1024));
const footer = QMCFooter.parse(buffer); const footer = QMCFooter.parse(buffer);
return footer?.mediaName || null; return footer?.mediaName || null;
} catch { } catch {
return null; return null;
} }
});
}; };

View File

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

@ -1,8 +1,8 @@
import { parseKuwoHeader } from '~/crypto/parseKuwo';
import type { RootState } from '~/store'; import type { RootState } from '~/store';
import { closestByLevenshtein } from '~/util/levenshtein'; import { closestByLevenshtein } from '~/util/levenshtein';
import { hasOwn } from '~/util/objects'; import { hasOwn } from '~/util/objects';
import { kwm2StagingToProductionKey } from './keyFormats'; import { kwm2StagingToProductionKey } from './keyFormats';
import type { ParseKuwoHeaderResponse } from '~/decrypt-worker/types.ts';
export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty; export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty;
@ -31,14 +31,16 @@ export const selectQMCv2KeyByFileName = (state: RootState, name: string): string
return ekey; return ekey;
}; };
export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => { export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse): string | undefined => {
const hdr = parseKuwoHeader(headerView);
if (!hdr) { if (!hdr) {
return; return;
} }
const quality = String(hdr.qualityId);
const rid = String(hdr.resourceId);
const keys = selectFinalKWMv2Keys(state); const keys = selectFinalKWMv2Keys(state);
const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality: hdr.quality, rid: hdr.rid }); const lookupKey = kwm2StagingToProductionKey({ id: '', ekey: '', quality, rid });
let ekey: string | undefined; let ekey: string | undefined;
if (hasOwn(keys, lookupKey)) { if (hasOwn(keys, lookupKey)) {