diff --git a/package.json b/package.json index 2a6ecb5..5c92824 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@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", "nanoid": "^5.0.4", "radash": "^11.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21007bd..5c77092 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,8 +39,8 @@ importers: 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) '@unlock-music/crypto': - specifier: 0.0.0-alpha.16 - version: 0.0.0-alpha.16 + specifier: 0.0.0-alpha.18 + version: 0.0.0-alpha.18 framer-motion: specifier: ^10.16.16 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': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@unlock-music/crypto@0.0.0-alpha.16': - 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} + '@unlock-music/crypto@0.0.0-alpha.18': + 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': resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} @@ -6110,7 +6110,7 @@ snapshots: '@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))': dependencies: diff --git a/src/crypto/parseKuwo.ts b/src/crypto/parseKuwo.ts deleted file mode 100644 index 5de9b1d..0000000 --- a/src/crypto/parseKuwo.ts +++ /dev/null @@ -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, - }; -} diff --git a/src/crypto/strlen.ts b/src/crypto/strlen.ts deleted file mode 100644 index b4d67df..0000000 --- a/src/crypto/strlen.ts +++ /dev/null @@ -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; -} diff --git a/src/decrypt-worker/constants.ts b/src/decrypt-worker/constants.ts index 5a9bdce..82cd52c 100644 --- a/src/decrypt-worker/constants.ts +++ b/src/decrypt-worker/constants.ts @@ -1,6 +1,7 @@ export enum DECRYPTION_WORKER_ACTION_NAME { DECRYPT = 'DECRYPT', FIND_QMC_MUSICEX_NAME = 'FIND_QMC_MUSICEX_NAME', + KUWO_PARSE_HEADER = 'KUWO_PARSE_HEADER', QINGTING_FM_GET_DEVICE_KEY = 'QINGTING_FM_GET_DEVICE_KEY', VERSION = 'VERSION', } diff --git a/src/decrypt-worker/types.ts b/src/decrypt-worker/types.ts index f83826d..a36f66c 100644 --- a/src/decrypt-worker/types.ts +++ b/src/decrypt-worker/types.ts @@ -12,10 +12,18 @@ export interface DecryptCommandPayload { } export interface FetchMusicExNamePayload { - id: string; blobURI: string; } +export interface ParseKuwoHeaderPayload { + blobURI: string; +} + +export type ParseKuwoHeaderResponse = null | { + resourceId: number; + qualityId: number; +}; + export interface GetQingTingFMDeviceKeyPayload { product: string; device: string; diff --git a/src/decrypt-worker/worker.ts b/src/decrypt-worker/worker.ts index 600bb91..c5862d7 100644 --- a/src/decrypt-worker/worker.ts +++ b/src/decrypt-worker/worker.ts @@ -5,6 +5,7 @@ import { getUmcVersion } from '@unlock-music/crypto'; 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'; const bus = new WorkerServerBus(); onmessage = bus.onmessage; @@ -12,4 +13,5 @@ onmessage = bus.onmessage; bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.DECRYPT, workerDecryptHandler); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, workerParseMusicExMediaName); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.VERSION, getUmcVersion); +bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, workerParseKuwoHeader); bus.addEventHandler(DECRYPTION_WORKER_ACTION_NAME.QINGTING_FM_GET_DEVICE_KEY, workerGetQtfmDeviceKey); diff --git a/src/decrypt-worker/worker/kuwo_header_parse.ts b/src/decrypt-worker/worker/kuwo_header_parse.ts new file mode 100644 index 0000000..dc00439 --- /dev/null +++ b/src/decrypt-worker/worker/kuwo_header_parse.ts @@ -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 => { + 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; + } +}; diff --git a/src/decrypt-worker/worker/qmcv2_parser.ts b/src/decrypt-worker/worker/qmcv2_parser.ts index 0b9d8d2..032b928 100644 --- a/src/decrypt-worker/worker/qmcv2_parser.ts +++ b/src/decrypt-worker/worker/qmcv2_parser.ts @@ -1,19 +1,15 @@ import type { FetchMusicExNamePayload } from '~/decrypt-worker/types.ts'; -import { timedLogger } from '~/util/logUtils.ts'; import { QMCFooter } from '@unlock-music/crypto'; -export const workerParseMusicExMediaName = async ({ id, blobURI }: FetchMusicExNamePayload) => { - const label = `qmcMusixExDetectName(${id.replace('://', ':')})`; - return timedLogger(label, async () => { - const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()); - const arrayBuffer = await blob.arrayBuffer(); +export const workerParseMusicExMediaName = async ({ blobURI }: FetchMusicExNamePayload) => { + const blob = await fetch(blobURI, { headers: { Range: 'bytes=-1024' } }).then((r) => r.blob()); + const arrayBuffer = await blob.arrayBuffer(); - try { - const buffer = new Uint8Array(arrayBuffer.slice(-1024)); - const footer = QMCFooter.parse(buffer); - return footer?.mediaName || null; - } catch { - return null; - } - }); + try { + const buffer = new Uint8Array(arrayBuffer.slice(-1024)); + const footer = QMCFooter.parse(buffer); + return footer?.mediaName || null; + } catch { + return null; + } }; diff --git a/src/features/file-listing/fileListingSlice.ts b/src/features/file-listing/fileListingSlice.ts index 81e5180..54cb9f4 100644 --- a/src/features/file-listing/fileListingSlice.ts +++ b/src/features/file-listing/fileListingSlice.ts @@ -1,12 +1,17 @@ -import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; import type { PayloadAction } from '@reduxjs/toolkit'; +import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import type { RootState } from '~/store'; 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 { DecryptErrorType } from '~/decrypt-worker/util/DecryptError'; -import { selectQMCv2KeyByFileName, selectKWMv2Key, selectQtfmAndroidKey } from '../settings/settingsSelector'; +import { selectKWMv2Key, selectQMCv2KeyByFileName, selectQtfmAndroidKey } from '../settings/settingsSelector'; export enum ProcessState { QUEUED = 'QUEUED', @@ -43,6 +48,7 @@ export interface FileListingState { files: Record; displayMode: ListingMode; } + const initialState: FileListingState = { files: {}, displayMode: ListingMode.LIST, @@ -64,28 +70,20 @@ export const processFile = createAsyncThunk< thunkAPI.dispatch(setFileAsProcessing({ id: fileId })); }; - const fileHeader = await fetch(file.raw, { headers: { Range: 'bytes=0-1023' } }) - .then((r) => r.blob()) - .then((r) => r.arrayBuffer()) - .then((r) => { - if (r.byteLength > 1024) { - return r.slice(0, 1024); - } - return r; - }); - - const qmcv2MusicExMediaFile = await workerClientBus.request( - DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, - { - id: fileId, + const [qmcv2MusicExMediaFile, kuwoHdr] = await Promise.all([ + workerClientBus.request(DECRYPTION_WORKER_ACTION_NAME.FIND_QMC_MUSICEX_NAME, { blobURI: file.raw, - }, - ); + }), + workerClientBus.request( + DECRYPTION_WORKER_ACTION_NAME.KUWO_PARSE_HEADER, + { blobURI: file.raw }, + ), + ]); const options: DecryptCommandOptions = { fileName: file.fileName, qmc2Key: selectQMCv2KeyByFileName(state, qmcv2MusicExMediaFile || file.fileName), - kwm2key: selectKWMv2Key(state, new DataView(fileHeader)), + kwm2key: selectKWMv2Key(state, kuwoHdr), qingTingAndroidKey: selectQtfmAndroidKey(state), }; return decryptionQueue.add({ id: fileId, blobURI: file.raw, options }, onPreProcess); diff --git a/src/features/settings/settingsSelector.ts b/src/features/settings/settingsSelector.ts index e61ecf8..4d37903 100644 --- a/src/features/settings/settingsSelector.ts +++ b/src/features/settings/settingsSelector.ts @@ -1,8 +1,8 @@ -import { parseKuwoHeader } from '~/crypto/parseKuwo'; 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'; export const selectIsSettingsNotSaved = (state: RootState) => state.settings.dirty; @@ -31,14 +31,16 @@ export const selectQMCv2KeyByFileName = (state: RootState, name: string): string return ekey; }; -export const selectKWMv2Key = (state: RootState, headerView: DataView): string | undefined => { - const hdr = parseKuwoHeader(headerView); +export const selectKWMv2Key = (state: RootState, hdr: ParseKuwoHeaderResponse): string | undefined => { if (!hdr) { return; } + const quality = String(hdr.qualityId); + const rid = String(hdr.resourceId); + 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; if (hasOwn(keys, lookupKey)) {