├── .nvmrc ├── testdata ├── qmc0_static_suffix.bin ├── mgg_map_raw.bin ├── mflac0_rc4_raw.bin ├── mflac_map_raw.bin ├── mflac_rc4_raw.bin ├── mgg_map_target.bin ├── mflac0_rc4_suffix.bin ├── mflac0_rc4_target.bin ├── mflac_map_target.bin ├── mflac_rc4_suffix.bin ├── mflac_rc4_target.bin ├── qmc0_static_raw.bin ├── qmc0_static_target.bin ├── mflac_map_key.bin ├── mgg_map_key.bin ├── mgg_map_key_raw.bin ├── mflac_map_key_raw.bin ├── mflac_map_suffix.bin ├── mgg_map_suffix.bin ├── mflac_rc4_key.bin ├── mflac0_rc4_key.bin ├── mflac0_rc4_key_raw.bin └── mflac_rc4_key_raw.bin ├── .browserslistrc ├── src ├── utils │ ├── __mocks__ │ │ ├── qm_meta.ts │ │ └── storage.ts │ ├── storage.ts │ ├── worker.ts │ ├── MergeUint8Array.ts │ ├── storage │ │ ├── StorageFactory.ts │ │ ├── BaseStorage.ts │ │ ├── InMemoryStorage.ts │ │ ├── BrowserNativeStorage.ts │ │ └── ChromeExtensionStorage.ts │ ├── utils.ts │ ├── tea.test.ts │ ├── tea.ts │ ├── api.ts │ └── qm_meta.ts ├── QmcWasm │ ├── TencentTea.hpp │ ├── qmc_cipher.hpp │ ├── README.md │ ├── QmcWasm.h │ ├── build-wasm │ ├── QmcWasm.cpp │ ├── CMakeLists.txt │ ├── qmc.hpp │ ├── qmc_key.hpp │ └── base64.hpp ├── shims-vue.d.ts ├── __test__ │ └── setup_jest.js ├── decrypt │ ├── __test__ │ │ ├── fixture │ │ │ ├── joox_1.bin │ │ │ └── qmc_cache_expected.bin │ │ └── joox.test.ts │ ├── entity.ts │ ├── tm.ts │ ├── qmc_key.test.ts │ ├── qmc.test.ts │ ├── raw.ts │ ├── ncmcache.ts │ ├── joox.ts │ ├── kgm_wasm.ts │ ├── qmc_wasm.ts │ ├── qmccache.ts │ ├── xm.ts │ ├── kwm.ts │ ├── mg3d.ts │ ├── kgm.ts │ ├── qmc_cipher.test.ts │ ├── qmc_key.ts │ ├── index.ts │ ├── qmc.ts │ ├── qmc_cipher.ts │ ├── ncm.ts │ └── utils.ts ├── extension │ ├── popup.js │ └── popup.html ├── KgmWasm │ ├── README.md │ ├── KgmWasm.cpp │ ├── KgmWasm.h │ ├── build-wasm │ ├── CMakeLists.txt │ └── kgm.hpp ├── component │ ├── Ruby.vue │ ├── PreviewTable.vue │ ├── ConfigDialog.vue │ ├── FileSelector.vue │ └── EditDialog.vue ├── shims-tsx.d.ts ├── shims-browser-id3-writer.d.ts ├── scss │ ├── unlock-music.scss │ ├── _normal.scss │ ├── _gaps.scss │ ├── _element-ui-overrite.scss │ ├── _variables.scss │ ├── _utility.scss │ └── _dark-mode.scss ├── registerServiceWorker.js ├── main.ts ├── shims-fs.d.ts ├── App.vue └── view │ └── Home.vue ├── public ├── favicon.ico ├── img │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── msapplication-icon-144x144.png │ │ └── safari-pinned-tab.svg ├── loader.js └── index.html ├── postcss.config.js ├── scripts ├── build-wasm.sh ├── upload-packages.sh └── build-and-package.sh ├── jest.config.js ├── babel.config.js ├── Dockerfile ├── .gitlab └── ISSUE_TEMPLATE │ ├── new-feature.md │ └── bug-report.md ├── extension-manifest.json ├── .gitignore ├── patches └── threads+1.7.0.patch ├── .drone.yml ├── tsconfig.json ├── make-extension.js ├── .gitlab-ci.yml ├── .prettierrc.js ├── LICENSE ├── vue.config.js ├── package.json └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.1 2 | -------------------------------------------------------------------------------- /testdata/qmc0_static_suffix.bin: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /src/utils/__mocks__/qm_meta.ts: -------------------------------------------------------------------------------- 1 | export const extractQQMusicMeta = jest.fn(); 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /testdata/mgg_map_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mgg_map_raw.bin -------------------------------------------------------------------------------- /src/QmcWasm/TencentTea.hpp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/src/QmcWasm/TencentTea.hpp -------------------------------------------------------------------------------- /src/QmcWasm/qmc_cipher.hpp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/src/QmcWasm/qmc_cipher.hpp -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /testdata/mflac0_rc4_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac0_rc4_raw.bin -------------------------------------------------------------------------------- /testdata/mflac_map_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac_map_raw.bin -------------------------------------------------------------------------------- /testdata/mflac_rc4_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac_rc4_raw.bin -------------------------------------------------------------------------------- /testdata/mgg_map_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mgg_map_target.bin -------------------------------------------------------------------------------- /testdata/mflac0_rc4_suffix.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac0_rc4_suffix.bin -------------------------------------------------------------------------------- /testdata/mflac0_rc4_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac0_rc4_target.bin -------------------------------------------------------------------------------- /testdata/mflac_map_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac_map_target.bin -------------------------------------------------------------------------------- /testdata/mflac_rc4_suffix.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac_rc4_suffix.bin -------------------------------------------------------------------------------- /testdata/mflac_rc4_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/mflac_rc4_target.bin -------------------------------------------------------------------------------- /testdata/qmc0_static_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/qmc0_static_raw.bin -------------------------------------------------------------------------------- /src/__test__/setup_jest.js: -------------------------------------------------------------------------------- 1 | // Polyfill for node. 2 | global.Blob = global.Blob || require("node:buffer").Blob; 3 | -------------------------------------------------------------------------------- /testdata/qmc0_static_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/testdata/qmc0_static_target.bin -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/utils/__mocks__/storage.ts: -------------------------------------------------------------------------------- 1 | export const storage = { 2 | loadJooxUUID: jest.fn(), 3 | saveJooxUUID: jest.fn(), 4 | }; 5 | -------------------------------------------------------------------------------- /src/decrypt/__test__/fixture/joox_1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/src/decrypt/__test__/fixture/joox_1.bin -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import storageFactory from './storage/StorageFactory'; 2 | 3 | export const storage = storageFactory(); 4 | -------------------------------------------------------------------------------- /src/utils/worker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from 'threads/worker'; 2 | import { Decrypt } from '@/decrypt'; 3 | 4 | expose(Decrypt); 5 | -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /src/extension/popup.js: -------------------------------------------------------------------------------- 1 | const bs = chrome || browser; 2 | bs.tabs.create({ url: bs.runtime.getURL('./index.html') }, (tab) => console.log(tab)); 3 | -------------------------------------------------------------------------------- /src/decrypt/__test__/fixture/qmc_cache_expected.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ipid/unlock-music/HEAD/src/decrypt/__test__/fixture/qmc_cache_expected.bin -------------------------------------------------------------------------------- /src/KgmWasm/README.md: -------------------------------------------------------------------------------- 1 | # KgmWasm 2 | 3 | ## 构建 4 | 5 | 在 Linux 环境下执行 `bash build-wasm` 即可构建。 6 | 7 | ## Build 8 | 9 | Linux environment required. Build wasm binary by execute `bash build-wasm`. 10 | -------------------------------------------------------------------------------- /src/QmcWasm/README.md: -------------------------------------------------------------------------------- 1 | # QmcWasm 2 | 3 | ## 构建 4 | 5 | 在 Linux 环境下执行 `bash build-wasm` 即可构建。 6 | 7 | ## Build 8 | 9 | Linux environment required. Build wasm binary by execute `bash build-wasm`. 10 | -------------------------------------------------------------------------------- /scripts/build-wasm.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -ex 4 | 5 | cd "$(git rev-parse --show-toplevel)" 6 | 7 | pushd ./src/QmcWasm 8 | bash build-wasm 9 | popd 10 | 11 | pushd ./src/KgmWasm 12 | bash build-wasm 13 | popd 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testPathIgnorePatterns: ['/build/', '/dist/', '/node_modules/'], 3 | setupFilesAfterEnv: ['./src/__test__/setup_jest.js'], 4 | moduleNameMapper: { 5 | '@/(.*)': '/src/$1', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /testdata/mflac_map_key.bin: -------------------------------------------------------------------------------- 1 | yw7xWOyNQ8585Jwx3hjB49QLPKi38F89awnrQ0fq66NT9TDq1ppHNrFqhaDrU5AFk916D58I53h86304GqOFCCyFzBem68DqiXJ81bILEQwG3P3MOnoNzM820kNW9Lv9IJGNn9Xo497p82BLTm4hAX8JLBs0T2pilKvT429sK9jfg508GSk4d047Jxdz5Fou4aa33OkyFRBU3x430mgNBn04Lc9BzXUI2IGYXv3FGa9qE4Vb54kSjVv8ogbg47j3 -------------------------------------------------------------------------------- /testdata/mgg_map_key.bin: -------------------------------------------------------------------------------- 1 | zGxNk54pKJ0hDkAo80wHE80ycSWQ7z4m4E846zVy2sqCn14F42Y5S7GqeR11WpOV75sDLbE5dFP992t88l0pHy1yAQ49YK6YX6c543drBYLo55Hc4Y0Fyic6LQPiGqu2bG31r8vaq9wS9v63kg0X5VbnOD6RhO4t0RRhk3ajrA7p0iIy027z0L70LZjtw6E18H0D41nz6ASTx71otdF9z1QNC0JmCl51xvnb39zPExEXyKkV47S6QsK5hFh884QJ -------------------------------------------------------------------------------- /src/extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app', 4 | '@babel/preset-typescript' 5 | ], 6 | plugins: [ 7 | ["component", { 8 | "libraryName": "element-ui", 9 | "styleLibraryName": "theme-chalk" 10 | }] 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /src/component/Ruby.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /testdata/mgg_map_key_raw.bin: -------------------------------------------------------------------------------- 1 | ekd4Tms1NHC53JEDO/AKVyF+I0bj0hHB7CZeoLDGSApaQB9Oo/pJTBGA/RO+nk5RXLXdHsffLiY4e8kt3LNo6qMl7S89vkiSFxx4Uoq4bGDJ7Jc+bYL6lLsa3M4sBvXS4XcPChrMDz+LmrJMGG6ua2fYyIz1d6TCRUBf1JJgCIkBbDAEeMVYc13qApitiz/apGAPmAnveCaDhfD5GxWsF+RfQ2OcnvrnIXe80Feh/0jx763DlsOBI3eIede6t5zYHokWkZmVEF1jMrnlvsgbQK2EzUWMblmLMsTKNILyZazEoKUyulqmyLO/c/KYE+USPOXPcbjlYFmLhSGHK7sQB5aBR153Yp+xh61ooh2NGAA= -------------------------------------------------------------------------------- /testdata/mflac_map_key_raw.bin: -------------------------------------------------------------------------------- 1 | eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88= -------------------------------------------------------------------------------- /testdata/mflac_map_suffix.bin: -------------------------------------------------------------------------------- 1 | eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88=l -------------------------------------------------------------------------------- /testdata/mgg_map_suffix.bin: -------------------------------------------------------------------------------- 1 | ekd4Tms1NHC53JEDO/AKVyF+I0bj0hHB7CZeoLDGSApaQB9Oo/pJTBGA/RO+nk5RXLXdHsffLiY4e8kt3LNo6qMl7S89vkiSFxx4Uoq4bGDJ7Jc+bYL6lLsa3M4sBvXS4XcPChrMDz+LmrJMGG6ua2fYyIz1d6TCRUBf1JJgCIkBbDAEeMVYc13qApitiz/apGAPmAnveCaDhfD5GxWsF+RfQ2OcnvrnIXe80Feh/0jx763DlsOBI3eIede6t5zYHokWkZmVEF1jMrnlvsgbQK2EzUWMblmLMsTKNILyZazEoKUyulqmyLO/c/KYE+USPOXPcbjlYFmLhSGHK7sQB5aBR153Yp+xh61ooh2NGAA=l -------------------------------------------------------------------------------- /src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | 8 | // tslint:disable no-empty-interface 9 | interface ElementClass extends Vue {} 10 | 11 | interface IntrinsicElements { 12 | [elem: string]: any; 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/MergeUint8Array.ts: -------------------------------------------------------------------------------- 1 | export function MergeUint8Array(array: Uint8Array[]): Uint8Array { 2 | let length = 0; 3 | array.forEach((item) => { 4 | length += item.length; 5 | }); 6 | 7 | let mergedArray = new Uint8Array(length); 8 | let offset = 0; 9 | array.forEach((item) => { 10 | mergedArray.set(item, offset); 11 | offset += item.length; 12 | }); 13 | 14 | return mergedArray; 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$TARGETPLATFORM nginx:stable-alpine 2 | 3 | LABEL org.opencontainers.image.title="Unlock Music" 4 | LABEL org.opencontainers.image.description="Unlock encrypted music file in browser" 5 | LABEL org.opencontainers.image.authors="MengYX" 6 | LABEL org.opencontainers.image.source="https://github.com/ix64/unlock-music" 7 | LABEL org.opencontainers.image.licenses="MIT" 8 | LABEL maintainer="MengYX" 9 | 10 | COPY ./dist /usr/share/nginx/html 11 | -------------------------------------------------------------------------------- /.gitlab/ISSUE_TEMPLATE/new-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 新功能 3 | about: 对于程序新的想法或建议 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | - 请按照此模板填写,否则可能立即被关闭 11 | 12 | **背景和说明** 13 | 14 | 简要说明产生此想法的背景和此想法的具体内容 15 | 16 | 17 | **实现途径** 18 | 19 | - 如果没有设计方案,请简要描述实现思路 20 | - 如果你没有任何的实现思路,请通过[Discussions](https://github.com/ix64/unlock-music/discussions)或者Telegram进行讨论 21 | 22 | 23 | **附加信息** 24 | 25 | 更多你想要表达的内容 26 | 27 | -------------------------------------------------------------------------------- /extension-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "音乐解锁", 4 | "short_name": "音乐解锁", 5 | "icons": { 6 | "128": "./img/icons/msapplication-icon-144x144.png" 7 | }, 8 | "description": "在任何设备上解锁已购的加密音乐!", 9 | "permissions": ["storage"], 10 | "offline_enabled": true, 11 | "options_page": "./index.html", 12 | "homepage_url": "https://github.com/ix64/unlock-music", 13 | "browser_action": { 14 | "default_popup": "./popup.html" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/decrypt/entity.ts: -------------------------------------------------------------------------------- 1 | export interface DecryptResult { 2 | title: string; 3 | album?: string; 4 | artist?: string; 5 | 6 | mime: string; 7 | ext: string; 8 | 9 | file: string; 10 | blob: Blob; 11 | picture?: string; 12 | 13 | message?: string; 14 | rawExt?: string; 15 | rawFilename?: string; 16 | } 17 | 18 | export interface FileInfo { 19 | status: string; 20 | name: string; 21 | size: number; 22 | percentage: number; 23 | uid: number; 24 | raw: File; 25 | } 26 | -------------------------------------------------------------------------------- /src/KgmWasm/KgmWasm.cpp: -------------------------------------------------------------------------------- 1 | // KgmWasm.cpp : Defines the entry point for the application. 2 | // 3 | 4 | #include "KgmWasm.h" 5 | 6 | #include "kgm.hpp" 7 | 8 | #include 9 | #include 10 | 11 | size_t preDec(uintptr_t blob, size_t blobSize, std::string ext) 12 | { 13 | return PreDec((uint8_t*)blob, blobSize, ext == "vpr"); 14 | } 15 | 16 | void decBlob(uintptr_t blob, size_t blobSize, size_t offset) 17 | { 18 | Decrypt((uint8_t*)blob, blobSize, offset); 19 | return; 20 | } 21 | -------------------------------------------------------------------------------- /testdata/mflac_rc4_key.bin: -------------------------------------------------------------------------------- 1 | pUtyvqr0TgAvR95mNmY7DmNl386TsJNAEIz95CEcgIgJCcs28686O7llxD5E74ldn70xMtd5cG58TA5ILw09I8BOTf5EdHKd6wwPn689DUK13y3Req6H0P33my2miJ5bQ2AA22B8vp4V0NJ3hBqNtFf7cId48V6W51e1kwgu1xKKawxe9BByT92MFlqrFaKH32dB2zFgyd38l2P1outr4l2XLq48F9G17ptRz4W8Loxu28RvZgv0BzL26Ht9I2L5VCwMzzt7OeZ55iQs40Tr6k81QGraIUJj5zeBMgJRMTaSgi19hU5x5a08Qd662MbFhZZ0FjVvaDy1nbIDhrC62c1lX6wf70O45h4W42VxloBVeZ9Sef4V7cWrjrEjj3DJ5w2iu6Q9uoal2f4390kue42Um5HcDFWqv3m56k6O89bRV424PaRra1k9Cd2L56IN2zfBYqNo2WP5VC68G8w1hfflOY0O52h4WdcpoHSjZm4b35N7l47dT4dwEXj1U4J5 -------------------------------------------------------------------------------- /testdata/mflac0_rc4_key.bin: -------------------------------------------------------------------------------- 1 | dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /build 5 | /coverage 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | /src/KgmWasm/build 26 | /src/KgmWasm/*.js 27 | /src/KgmWasm/*.wasm 28 | /src/QmcWasm/build 29 | /src/QmcWasm/*.js 30 | /src/QmcWasm/*.wasm 31 | 32 | *.zip 33 | *.tar.gz 34 | /sha256sum.txt 35 | -------------------------------------------------------------------------------- /patches/threads+1.7.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/threads/worker.mjs b/node_modules/threads/worker.mjs 2 | index c53ac7d..619007b 100644 3 | --- a/node_modules/threads/worker.mjs 4 | +++ b/node_modules/threads/worker.mjs 5 | @@ -1,4 +1,5 @@ 6 | -import WorkerContext from "./dist/worker/index.js" 7 | +// Workaround: use of import seems to break minifier. 8 | +const WorkerContext = require("./dist/worker/index.js") 9 | 10 | export const expose = WorkerContext.expose 11 | export const registerSerializer = WorkerContext.registerSerializer 12 | -------------------------------------------------------------------------------- /src/utils/storage/StorageFactory.ts: -------------------------------------------------------------------------------- 1 | import BaseStorage from './BaseStorage'; 2 | import BrowserNativeStorage from './BrowserNativeStorage'; 3 | import ChromeExtensionStorage from './ChromeExtensionStorage'; 4 | import InMemoryStorage from './InMemoryStorage'; 5 | 6 | export default function storageFactory(): BaseStorage { 7 | if (ChromeExtensionStorage.works) { 8 | return new ChromeExtensionStorage(); 9 | } else if (BrowserNativeStorage.works) { 10 | return new BrowserNativeStorage(); 11 | } 12 | return new InMemoryStorage(); 13 | } 14 | -------------------------------------------------------------------------------- /src/KgmWasm/KgmWasm.h: -------------------------------------------------------------------------------- 1 | // KgmWasm.h : Include file for standard system include files, 2 | // or project specific include files. 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | 9 | namespace em = emscripten; 10 | 11 | size_t preDec(uintptr_t blob, size_t blobSize, std::string ext); 12 | void decBlob(uintptr_t blob, size_t blobSize, size_t offset); 13 | 14 | EMSCRIPTEN_BINDINGS(QmcCrypto) 15 | { 16 | em::function("preDec", &preDec, em::allow_raw_pointers()); 17 | em::function("decBlob", &decBlob, em::allow_raw_pointers()); 18 | } 19 | -------------------------------------------------------------------------------- /src/shims-browser-id3-writer.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'browser-id3-writer' { 2 | export default class ID3Writer { 3 | constructor(buffer: Buffer | ArrayBuffer); 4 | 5 | setFrame(name: string, value: string | object | string[]); 6 | 7 | addTag(): Uint8Array; 8 | } 9 | } 10 | 11 | declare module 'metaflac-js' { 12 | export default class Metaflac { 13 | constructor(buffer: Buffer); 14 | 15 | setTag(field: string); 16 | 17 | removeTag(name: string); 18 | 19 | importPictureFromBuffer(picture: Buffer); 20 | 21 | save(): Buffer; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /scripts/upload-packages.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | cd "$(git rev-parse --show-toplevel)" 6 | 7 | if [ -z "$GITEA_API_KEY" ]; then 8 | echo "GITEA_API_KEY is empty, skip upload." 9 | exit 0 10 | fi 11 | 12 | URL_BASE="$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build" 13 | 14 | for ZIP_NAME in *.zip; do 15 | UPLOAD_URL="${URL_BASE}/${DRONE_BUILD_NUMBER}/${ZIP_NAME}" 16 | sha256sum "${ZIP_NAME}" 17 | curl -sLifu "um-release-bot:$GITEA_API_KEY" -T "${ZIP_NAME}" "${UPLOAD_URL}" 18 | echo "Uploaded to: ${UPLOAD_URL}" 19 | done 20 | -------------------------------------------------------------------------------- /scripts/build-and-package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | cd "$(git rev-parse --show-toplevel)" 6 | 7 | VERSION="$(jq -r ".version" { 8 | const audioData = new Uint8Array(await GetArrayBuffer(file)); 9 | for (let cur = 0; cur < 8; ++cur) { 10 | audioData[cur] = TM_HEADER[cur]; 11 | } 12 | const musicData = new Blob([audioData], { type: 'audio/mp4' }); 13 | return await RawDecrypt(musicData, raw_filename, 'm4a', false); 14 | } 15 | -------------------------------------------------------------------------------- /src/scss/unlock-music.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "utility"; 3 | @import "gaps"; 4 | @import "element-ui-overrite"; 5 | 6 | @import "normal"; 7 | @import "dark-mode"; // dark-mode 放在 normal 后面,以获得更高优先级 8 | 9 | 10 | // 首页弹窗提示信息的 更新信息 面板 11 | .update-info{ 12 | @include border-radius(8px); 13 | overflow: hidden; 14 | border: 1px solid $color-border-el; 15 | margin: 10px 0; 16 | .update-title{ 17 | font-size: $fz-mini-title; 18 | padding: 5px 10px; 19 | background-color: $color-border-el; 20 | } 21 | .update-content{ 22 | font-size: $fz-mini-content; 23 | line-height: 1.5; 24 | padding: 10px; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitlab/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug报告 3 | about: 报告Bug以帮助改进程序 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | * 请按照此模板填写,否则可能立即被关闭 11 | 12 | - [x] 我确认已经搜索过Issue不存并确认相同的Issue 13 | - [x] 我有证据表明这是程序导致的问题(如不确认,可以在[Discussions](https://github.com/ix64/unlock-music/discussions)内提出) 14 | 15 | 16 | **Bug描述** 17 | 18 | 简要地复述你遇到的Bug 19 | 20 | **复现方法** 21 | 22 | 描述复现方法,必要时请提供样本文件 23 | 24 | **程序截图或者Console报错信息** 25 | 26 | 如果可以请提供二者之一 27 | 28 | 29 | **环境信息:** 30 | 31 | - 操作系统和浏览器: 32 | - 程序版本: 33 | - 获取音乐文件所使用的客户端及其版本信息: 34 | 35 | 36 | **附加信息** 37 | 38 | 其他能够帮助确认问题的信息 39 | 40 | -------------------------------------------------------------------------------- /src/scss/_normal.scss: -------------------------------------------------------------------------------- 1 | body{ 2 | font-family: $font-family; 3 | font-size: $fz-main; 4 | -webkit-font-smoothing: antialiased; 5 | -moz-osx-font-smoothing: grayscale; 6 | } 7 | 8 | #app { 9 | text-align: center; 10 | color: $text-main; 11 | padding-top: 30px; 12 | } 13 | 14 | #app-footer a { 15 | padding-left: 0.2em; 16 | padding-right: 0.2em; 17 | } 18 | 19 | #app-footer { 20 | text-align: center; 21 | font-size: small; 22 | } 23 | 24 | #app-control { 25 | padding-top: 1em; 26 | padding-bottom: 1em; 27 | } 28 | 29 | audio{ 30 | margin-bottom: 15px; // 播放控件与表格间隔 31 | } 32 | 33 | a{ 34 | color: darken($color-link, 15%); 35 | &:hover{ 36 | color: $color-link; 37 | } 38 | } -------------------------------------------------------------------------------- /testdata/mflac0_rc4_key_raw.bin: -------------------------------------------------------------------------------- 1 | ZFJ6WDNwNVrjEJZB1o6QjkQV2ZbHSw/2Eb00q1+4z9SVWYyFWO1PcSQrJ5326ubLklmk2ab3AEyIKNUu8DFoAoAc9dpzpTmc+pdkBHjM/bW2jWx+dCyC8vMTHE+DHwaK14UEEGW47ZXMDi7PRCQ2Jpm/oXVdHTIlyrc+bRmKfMith0L2lFQ+nW8CCjV6ao5ydwkZhhNOmRdrCDcUXSJH9PveYwra9/wAmGKWSs9nemuMWKnbjp1PkcxNQexicirVTlLX7PVgRyFyzNyUXgu+R2S4WTmLwjd8UsOyW/dc2mEoYt+vY2lq1X4hFBtcQGOAZDeC+mxrN0EcW8tjS6P4TjOjiOKNMxIfMGSWkSKL3H7z5K7nR1AThW20H2bP/LcpsdaL0uZ/js1wFGpdIfFx9rnLC78itL0WwDleIqp9TBMX/NwakGgIPIbjBwfgyD8d8XKYuLEscIH0ZGdjsadB5XjybgdE3ppfeFEcQiqpnodlTaQRm3KDIF9ATClP0mTl8XlsSojsZ468xseS1Ib2iinx/0SkK3UtJDwp8DH3/+ELisgXd69Bf0pve7wbrQzzMUs9/Ogvvo6ULsIkQfApJ8cSegDYklzGXiLNH7hZYnXDLLSNejD7NvQouULSmGsBbGzhZ5If0NP/6AhSbpzqWLDlabTDgeWWnFeZpBnlK6SMxo+YFFk1Y0XLKsd69+jj -------------------------------------------------------------------------------- /testdata/mflac_rc4_key_raw.bin: -------------------------------------------------------------------------------- 1 | cFV0eXZxcjAF/IXJ9qJT1u5C3S5AgY9BoVtIQNBKfxQMt5hH7BF36ndIJGV5L6qw5h4G0IOIOOewdHmMCNfKJftHM4nv3B0iRlSdqJKdL08wO3sV0v8eZk0OiYAlxgseGcBquQWYS/0b5Lj/Ioi2NfpOthAY9vUiRPnfH3+7/2AJGudHjj4Gg1KkpPW3mXIKbsk+Ou9fhrUqs873BCdsmI6qRmVNhOkLaUcbG6Zin3XU0WkgnnjebR43S8N4bw5BTphFvhy42QvspnD7Ewb1tVZQMQ2N1s38nBjukdfCB9R6aRwITOvg2U7Lr0RjLpbrIn6A6iVilpINjK4VptuKUTlpDXQwgCjoqeHQaHNCWgYpdjB69lXn8km/BfzK7QyDbh0VgTikwAHF9tvPhin3AIDRcU0xsaWYKURRfJelX3pSN495ADlhXdEKL/+l60hVnY7t6iCMxJL3lOtdGtdUYUGUCc76PB1fX+0HTWCcfcwvXTEdczr9J1h2yTeJNqFQ5pNy8vX7Ws8k7vDQVFkw4llZjPhb0kg9aDNePTNIKSGwy/7eofrcUQlC9DI+qqqwQ5abA/93fNsPq6XU3uwawnrbBsdz8DDdjJiEDI7abkPIDIfr/uR0YzgBxW90t5bt6xAtuW+VSYAM7kGxI3RZTl7JgOT60MLyIWkYASrRhRPMGks8zL10ED/4yGTEB1nt -------------------------------------------------------------------------------- /src/QmcWasm/QmcWasm.h: -------------------------------------------------------------------------------- 1 | // QmcWasm.h : Include file for standard system include files, 2 | // or project specific include files. 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | 9 | namespace em = emscripten; 10 | 11 | int preDec(uintptr_t blob, size_t blobSize, std::string ext); 12 | size_t decBlob(uintptr_t blob, size_t blobSize, size_t offset); 13 | std::string getErr(); 14 | std::string getSongId(); 15 | 16 | EMSCRIPTEN_BINDINGS(QmcCrypto) 17 | { 18 | em::function("getErr", &getErr); 19 | em::function("getSongId", &getSongId); 20 | 21 | em::function("preDec", &preDec, em::allow_raw_pointers()); 22 | em::function("decBlob", &decBlob, em::allow_raw_pointers()); 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/storage/BaseStorage.ts: -------------------------------------------------------------------------------- 1 | export const KEY_PREFIX = 'um.conf.'; 2 | const KEY_JOOX_UUID = `${KEY_PREFIX}joox.uuid`; 3 | 4 | export default abstract class BaseStorage { 5 | protected abstract save(name: string, value: T): Promise; 6 | protected abstract load(name: string, defaultValue: T): Promise; 7 | public abstract getAll(): Promise>; 8 | public abstract setAll(obj: Record): Promise; 9 | 10 | public saveJooxUUID(uuid: string): Promise { 11 | return this.save(KEY_JOOX_UUID, uuid); 12 | } 13 | 14 | public loadJooxUUID(defaultValue: string = ''): Promise { 15 | return this.load(KEY_JOOX_UUID, defaultValue); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/scss/_gaps.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * 间隔工具集 3 | */ 4 | 5 | $gap: 5px; 6 | @for $item from 0 through 8 { 7 | .mt-#{$item} { margin-top : $gap * $item !important;} 8 | .mb-#{$item} { margin-bottom : $gap * $item !important;} 9 | .ml-#{$item} { margin-left : $gap * $item !important;} 10 | .mr-#{$item} { margin-right : $gap * $item !important;} 11 | .m-#{$item} { margin : $gap * $item !important;} 12 | 13 | .pt-#{$item} { padding-top : $gap * $item !important;} 14 | .pb-#{$item} { padding-bottom : $gap * $item !important;} 15 | .pl-#{$item} { padding-left : $gap * $item !important;} 16 | .pr-#{$item} { padding-right : $gap * $item !important;} 17 | .p-#{$item} { padding : $gap * $item !important;} 18 | } 19 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: docker 4 | name: default 5 | 6 | steps: 7 | - name: build-wasm 8 | image: emscripten/emsdk:3.0.0 9 | commands: 10 | - ./scripts/build-wasm.sh 11 | 12 | - name: build 13 | image: node:16.18-bullseye 14 | commands: 15 | - apt-get update 16 | - apt-get install -y jq zip 17 | - npm ci 18 | - npm run test 19 | - ./scripts/build-and-package.sh legacy 20 | - ./scripts/build-and-package.sh extension 21 | - ./scripts/build-and-package.sh modern 22 | 23 | - name: upload artifact 24 | image: node:16.18-bullseye 25 | environment: 26 | DRONE_GITEA_SERVER: https://git.unlock-music.dev 27 | GITEA_API_KEY: 28 | from_secret: GITEA_API_KEY 29 | commands: 30 | - ./scripts/upload-packages.sh 31 | -------------------------------------------------------------------------------- /src/QmcWasm/build-wasm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | pushd "$(realpath "$(dirname "$0")")" 6 | 7 | CURR_DIR="${PWD}" 8 | 9 | BUILD_TYPE="$1" 10 | if [ -z "$BUILD_TYPE" ]; then 11 | BUILD_TYPE=Release 12 | fi 13 | 14 | # CI: already had emsdk installed. 15 | if ! command -v emcc; then 16 | if [ ! -d ../../build/emsdk ]; then 17 | git clone https://github.com/emscripten-core/emsdk.git ../../build/emsdk 18 | fi 19 | 20 | pushd ../../build/emsdk 21 | ./emsdk install 3.0.0 22 | ./emsdk activate 3.0.0 23 | source ./emsdk_env.sh 24 | popd # ../../build/emsdk 25 | fi 26 | 27 | mkdir -p build/wasm 28 | pushd build/wasm 29 | emcmake cmake -DCMAKE_BUILD_TYPE="$BUILD_TYPE" ../.. 30 | make -j 31 | TARGET_FILES=" 32 | QmcLegacy.js 33 | QmcWasm.js 34 | QmcWasm.wasm 35 | QmcWasmBundle.js 36 | " 37 | 38 | cp $TARGET_FILES "${CURR_DIR}/" 39 | popd # build/wasm 40 | 41 | popd 42 | -------------------------------------------------------------------------------- /src/decrypt/qmc_key.test.ts: -------------------------------------------------------------------------------- 1 | import { QmcDeriveKey, simpleMakeKey } from '@/decrypt/qmc_key'; 2 | import fs from 'fs'; 3 | 4 | test('key dec: make simple key', () => { 5 | expect(simpleMakeKey(106, 8)).toStrictEqual([0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b]); 6 | }); 7 | 8 | function loadTestDataKeyDecrypt(name: string): { 9 | cipherText: Uint8Array; 10 | clearText: Uint8Array; 11 | } { 12 | return { 13 | cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`), 14 | clearText: fs.readFileSync(`testdata/${name}_key.bin`), 15 | }; 16 | } 17 | 18 | test('key dec: real file', async () => { 19 | const cases = ['mflac_map', 'mgg_map', 'mflac0_rc4', 'mflac_rc4']; 20 | for (const name of cases) { 21 | const { clearText, cipherText } = loadTestDataKeyDecrypt(name); 22 | const buf = QmcDeriveKey(cipherText); 23 | 24 | expect(buf).toStrictEqual(clearText); 25 | } 26 | }); 27 | -------------------------------------------------------------------------------- /src/scss/_element-ui-overrite.scss: -------------------------------------------------------------------------------- 1 | $color-checkbox: $blue; 2 | $color-border-el: #DCDFE6; 3 | 4 | $btn-radius: 6px; 5 | 6 | /* FORM */ 7 | // checkbox 8 | .el-checkbox.is-bordered{ 9 | @include border-radius($btn-radius) ; 10 | &:hover{ 11 | border-color: $color-checkbox; 12 | .el-checkbox__label{ 13 | color: $color-checkbox; 14 | } 15 | } 16 | .el-checkbox__input.is-focus{ 17 | .el-checkbox__inner{ 18 | border-color: $color-border-el; 19 | } 20 | } 21 | &.is-checked{ 22 | background-color: $color-checkbox; 23 | .el-checkbox__label{ 24 | color: white; 25 | } 26 | .el-checkbox__inner{ 27 | border-color: white; 28 | background-color: white; 29 | &:after{ 30 | border-color: $color-checkbox; 31 | } 32 | } 33 | } 34 | } 35 | 36 | // el-button 37 | .el-button{ 38 | @include border-radius($btn-radius) ; 39 | } 40 | -------------------------------------------------------------------------------- /src/KgmWasm/build-wasm: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | pushd "$(realpath "$(dirname "$0")")" 6 | 7 | CURR_DIR="${PWD}" 8 | 9 | BUILD_TYPE="$1" 10 | if [ -z "$BUILD_TYPE" ]; then 11 | BUILD_TYPE=Release 12 | fi 13 | 14 | # CI: already had emsdk installed. 15 | if ! command -v emcc; then 16 | if [ ! -d ../../build/emsdk ]; then 17 | git clone https://github.com/emscripten-core/emsdk.git ../../build/emsdk 18 | fi 19 | 20 | pushd ../../build/emsdk 21 | ./emsdk install 3.0.0 22 | ./emsdk activate 3.0.0 23 | source ./emsdk_env.sh 24 | popd # ../../build/emsdk 25 | fi 26 | 27 | mkdir -p build/wasm 28 | pushd build/wasm 29 | emcmake cmake -DCMAKE_BUILD_TYPE="$BUILD_TYPE" ../.. 30 | make -j 31 | TARGET_FILES=" 32 | KgmLegacy.js 33 | KgmWasm.js 34 | KgmWasm.wasm 35 | KgmWasmBundle.js 36 | " 37 | 38 | cp $TARGET_FILES "${CURR_DIR}/" 39 | popd # build/wasm 40 | 41 | popd 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "strict": true, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env", 18 | "jest" 19 | ], 20 | "paths": { 21 | "@/*": [ 22 | "src/*" 23 | ] 24 | }, 25 | "lib": [ 26 | "esnext", 27 | "dom", 28 | "dom.iterable", 29 | "scripthost" 30 | ], 31 | "resolveJsonModule": true 32 | }, 33 | "include": [ 34 | "src/**/*.ts", 35 | "src/**/*.tsx", 36 | "src/**/*.vue", 37 | "tests/**/*.ts", 38 | "tests/**/*.tsx" 39 | ], 40 | "exclude": [ 41 | "node_modules" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/storage/InMemoryStorage.ts: -------------------------------------------------------------------------------- 1 | import BaseStorage from './BaseStorage'; 2 | 3 | export default class InMemoryStorage extends BaseStorage { 4 | private values = new Map(); 5 | protected async load(name: string, defaultValue: T): Promise { 6 | if (this.values.has(name)) { 7 | return this.values.get(name); 8 | } 9 | 10 | return defaultValue; 11 | } 12 | 13 | protected async save(name: string, value: T): Promise { 14 | this.values.set(name, value); 15 | } 16 | 17 | public async getAll(): Promise> { 18 | const result = {}; 19 | this.values.forEach((value, key) => { 20 | Object.assign(result, { 21 | [key]: value, 22 | }); 23 | }); 24 | return result; 25 | } 26 | 27 | public async setAll(obj: Record): Promise { 28 | for (const [key, value] of Object.entries(obj)) { 29 | this.values.set(key, value); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /public/loader.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | setTimeout(function () { 3 | var ele = document.getElementById("loader-tips-timeout"); 4 | if (ele != null) { 5 | ele.hidden = false; 6 | } 7 | }, 2000); 8 | 9 | var ua = navigator && navigator.userAgent; 10 | var detected = (function () { 11 | var m; 12 | if (!ua) return true; 13 | if (/MSIE |Trident\//.exec(ua)) return true; // no IE 14 | m = /Edge\/([\d.]+)/.exec(ua); // Edge >= 17 15 | if (m && Number(m[1]) < 17) return true; 16 | m = /Chrome\/([\d.]+)/.exec(ua); // Chrome >= 58 17 | if (m && Number(m[1]) < 58) return true; 18 | m = /Firefox\/([\d.]+)/.exec(ua); // Firefox >= 45 19 | return m && Number(m[1]) < 45; 20 | })(); 21 | if (detected) { 22 | document.getElementById('loader-tips-outdated').hidden = false; 23 | document.getElementById("loader-tips-timeout").hidden = false; 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log('App is being served from cache by a service worker.'); 9 | }, 10 | registered() { 11 | console.log('Service worker has been registered.'); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updatefound() { 17 | console.log('New content is downloading.'); 18 | }, 19 | updated() { 20 | console.log('New content is available.'); 21 | window.location.reload(); 22 | }, 23 | offline() { 24 | console.log('No internet connection found. App is running in offline mode.'); 25 | }, 26 | error(error) { 27 | console.error('Error during service worker registration:', error); 28 | }, 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /make-extension.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const src = __dirname + "/src/extension/" 4 | const dst = __dirname + "/dist" 5 | fs.readdirSync(src).forEach(file => { 6 | let srcPath = path.join(src, file) 7 | let dstPath = path.join(dst, file) 8 | fs.copyFileSync(srcPath, dstPath) 9 | console.log(`Copy: ${srcPath} => ${dstPath}`) 10 | }) 11 | 12 | const manifestRaw = fs.readFileSync(__dirname + "/extension-manifest.json", "utf-8") 13 | const manifest = JSON.parse(manifestRaw) 14 | 15 | const pkgRaw = fs.readFileSync(__dirname + "/package.json", "utf-8") 16 | const pkg = JSON.parse(pkgRaw) 17 | 18 | verExt = pkg["version"] 19 | if (verExt.startsWith("v")) verExt = verExt.slice(1) 20 | if (verExt.includes("-")) verExt = verExt.split("-")[0] 21 | manifest["version"] = `${verExt}.${pkg["ext_build"]}` 22 | manifest["version_name"] = pkg["version"] 23 | 24 | fs.writeFileSync(__dirname + "/dist/manifest.json", JSON.stringify(manifest), "utf-8") 25 | console.log("Write: manifest.json") 26 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | image: node:16 2 | cache: 3 | paths: 4 | - node_modules/ 5 | 6 | stages: 7 | - build 8 | 9 | 10 | build-job: 11 | stage: build 12 | script: | 13 | sed -i 's/deb.debian.org/mirrors.cloud.tencent.com/g' /etc/apt/sources.list 14 | apt-get update 15 | apt-get -y install zip 16 | 17 | npm config set registry http://mirrors.cloud.tencent.com/npm/ 18 | npm ci 19 | 20 | npm run build 21 | tar -czf legacy.tar.gz -C ./dist . 22 | cd dist 23 | zip -rJ9 ../legacy.zip * 24 | cd .. 25 | 26 | npm run make-extension 27 | cd dist 28 | zip -rJ9 ../extension.zip * 29 | cd .. 30 | 31 | npm run build -- --modern 32 | tar -czf modern.tar.gz -C ./dist . 33 | cd dist 34 | zip -rJ9 ../modern.zip * 35 | cd .. 36 | 37 | sha256sum *.tar.gz *.zip > sha256sum.txt 38 | 39 | artifacts: 40 | name: "$CI_JOB_NAME" 41 | paths: 42 | - legacy.zip 43 | - legacy.tar.gz 44 | - extension.zip 45 | - modern.zip 46 | - modern.tar.gz 47 | - sha256sum.txt 48 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // .prettierrc.js 2 | module.exports = { 3 | // 一行最多 120 字符 4 | printWidth: 120, 5 | // 使用 2 个空格缩进 6 | tabWidth: 2, 7 | // 不使用缩进符,而使用空格 8 | useTabs: false, 9 | // 行尾需要有分号 10 | semi: true, 11 | // 使用单引号 12 | singleQuote: true, 13 | // 对象的 key 仅在必要时用引号 14 | quoteProps: 'as-needed', 15 | // jsx 不使用单引号,而使用双引号 16 | jsxSingleQuote: false, 17 | // 末尾需要有逗号 18 | trailingComma: 'all', 19 | // 大括号内的首尾需要空格 20 | bracketSpacing: true, 21 | // jsx 标签的反尖括号需要换行 22 | bracketSameLine: false, 23 | // 箭头函数,只有一个参数的时候,也需要括号 24 | arrowParens: 'always', 25 | // 每个文件格式化的范围是文件的全部内容 26 | rangeStart: 0, 27 | rangeEnd: Infinity, 28 | // 不需要写文件开头的 @prettier 29 | requirePragma: false, 30 | // 不需要自动在文件开头插入 @prettier 31 | insertPragma: false, 32 | // 使用默认的折行标准 33 | proseWrap: 'preserve', 34 | // 根据显示样式决定 html 要不要折行 35 | htmlWhitespaceSensitivity: 'css', 36 | // vue 文件中的 script 和 style 内不用缩进 37 | vueIndentScriptAndStyle: false, 38 | // 换行符使用 lf 39 | endOfLine: 'lf', 40 | // 格式化嵌入的内容 41 | embeddedLanguageFormatting: 'auto', 42 | }; 43 | -------------------------------------------------------------------------------- /src/decrypt/qmc.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { QmcDecoder } from '@/decrypt/qmc'; 3 | import { BytesEqual } from '@/decrypt/utils'; 4 | 5 | function loadTestDataDecoder(name: string): { 6 | cipherText: Uint8Array; 7 | clearText: Uint8Array; 8 | } { 9 | const cipherBody = fs.readFileSync(`./testdata/${name}_raw.bin`); 10 | const cipherSuffix = fs.readFileSync(`./testdata/${name}_suffix.bin`); 11 | const cipherText = new Uint8Array(cipherBody.length + cipherSuffix.length); 12 | cipherText.set(cipherBody); 13 | cipherText.set(cipherSuffix, cipherBody.length); 14 | return { 15 | cipherText, 16 | clearText: fs.readFileSync(`testdata/${name}_target.bin`), 17 | }; 18 | } 19 | 20 | test('qmc: real file', async () => { 21 | const cases = ['mflac0_rc4', 'mflac_rc4', 'mflac_map', 'mgg_map', 'qmc0_static']; 22 | for (const name of cases) { 23 | const { clearText, cipherText } = loadTestDataDecoder(name); 24 | const c = new QmcDecoder(cipherText); 25 | const buf = c.decrypt(); 26 | 27 | expect(BytesEqual(buf, clearText)).toBeTruthy(); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/decrypt/raw.ts: -------------------------------------------------------------------------------- 1 | import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils'; 2 | 3 | import { DecryptResult } from '@/decrypt/entity'; 4 | 5 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 6 | 7 | export async function Decrypt( 8 | file: Blob, 9 | raw_filename: string, 10 | raw_ext: string, 11 | detect: boolean = true, 12 | ): Promise { 13 | let ext = raw_ext; 14 | if (detect) { 15 | const buffer = new Uint8Array(await GetArrayBuffer(file)); 16 | ext = SniffAudioExt(buffer, raw_ext); 17 | if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] }); 18 | } 19 | const tag = await metaParseBlob(file); 20 | const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || '')); 21 | 22 | return { 23 | title, 24 | artist, 25 | ext, 26 | album: tag.common.album, 27 | picture: GetCoverFromFile(tag), 28 | file: URL.createObjectURL(file), 29 | blob: file, 30 | mime: AudioMimeType[ext], 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/decrypt/ncmcache.ts: -------------------------------------------------------------------------------- 1 | import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils'; 2 | 3 | import { DecryptResult } from '@/decrypt/entity'; 4 | 5 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 6 | 7 | export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { 8 | const buffer = new Uint8Array(await GetArrayBuffer(file)); 9 | let length = buffer.length; 10 | for (let i = 0; i < length; i++) { 11 | buffer[i] ^= 163; 12 | } 13 | const ext = SniffAudioExt(buffer, raw_ext); 14 | if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] }); 15 | const tag = await metaParseBlob(file); 16 | const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || "")); 17 | 18 | return { 19 | title, 20 | artist, 21 | ext, 22 | album: tag.common.album, 23 | picture: GetCoverFromFile(tag), 24 | file: URL.createObjectURL(file), 25 | blob: file, 26 | mime: AudioMimeType[ext], 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2023 MengYX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from '@/App.vue'; 3 | import '@/registerServiceWorker'; 4 | import { 5 | Button, 6 | Checkbox, 7 | Col, 8 | Container, 9 | Dialog, 10 | Form, 11 | FormItem, 12 | Footer, 13 | Icon, 14 | Image, 15 | Input, 16 | Link, 17 | Main, 18 | Notification, 19 | Progress, 20 | Radio, 21 | Row, 22 | Table, 23 | TableColumn, 24 | Tooltip, 25 | Upload, 26 | MessageBox, 27 | } from 'element-ui'; 28 | import 'element-ui/lib/theme-chalk/base.css'; 29 | 30 | Vue.use(Link); 31 | Vue.use(Image); 32 | Vue.use(Button); 33 | Vue.use(Dialog); 34 | Vue.use(Form); 35 | Vue.use(FormItem); 36 | Vue.use(Input); 37 | Vue.use(Table); 38 | Vue.use(TableColumn); 39 | Vue.use(Main); 40 | Vue.use(Footer); 41 | Vue.use(Container); 42 | Vue.use(Icon); 43 | Vue.use(Row); 44 | Vue.use(Col); 45 | Vue.use(Upload); 46 | Vue.use(Checkbox); 47 | Vue.use(Radio); 48 | Vue.use(Tooltip); 49 | Vue.use(Progress); 50 | Vue.prototype.$notify = Notification; 51 | Vue.prototype.$confirm = MessageBox.confirm; 52 | 53 | Vue.config.productionTip = false; 54 | new Vue({ 55 | render: (h) => h(App), 56 | }).$mount('#app'); 57 | -------------------------------------------------------------------------------- /src/QmcWasm/QmcWasm.cpp: -------------------------------------------------------------------------------- 1 | // QmcWasm.cpp : Defines the entry point for the application. 2 | // 3 | 4 | #include "QmcWasm.h" 5 | 6 | #include "qmc.hpp" 7 | 8 | #include 9 | #include 10 | 11 | std::string err = ""; 12 | std::string sid = ""; 13 | QmcDecode e; 14 | 15 | int preDec(uintptr_t blob, size_t blobSize, std::string ext) 16 | { 17 | if (!e.SetBlob((uint8_t*)blob, blobSize)) 18 | { 19 | err = "cannot allocate memory"; 20 | return -1; 21 | } 22 | int tailSize = e.PreDecode(ext); 23 | if (e.error != "") 24 | { 25 | err = e.error; 26 | return -1; 27 | } 28 | sid = e.songId; 29 | return tailSize; 30 | } 31 | 32 | size_t decBlob(uintptr_t blob, size_t blobSize, size_t offset) 33 | { 34 | if (!e.SetBlob((uint8_t*)blob, blobSize)) 35 | { 36 | err = "cannot allocate memory"; 37 | return 0; 38 | } 39 | std::vector decData = e.Decode(offset); 40 | if (e.error != "") 41 | { 42 | err = e.error; 43 | return 0; 44 | } 45 | memcpy((uint8_t*)blob, decData.data(), decData.size()); 46 | return decData.size(); 47 | } 48 | 49 | std::string getErr() 50 | { 51 | return err; 52 | } 53 | 54 | std::string getSongId() 55 | { 56 | return sid; 57 | } 58 | -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // COLORS 2 | $blue : #409EFF; 3 | $red : #F56C6C; 4 | $green : #85ce61; 5 | 6 | // TEXT 7 | $text-main : #2C3E50; 8 | $color-link: $blue; 9 | 10 | $fz-main: 14px; 11 | $fz-mini-title: 13px; 12 | $fz-mini-content: 12px; 13 | 14 | $font-family: "Helvetica Neue", Helvetica, "PingFang SC", 15 | "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif; 16 | 17 | 18 | // DARK MODE 19 | $dark-border : lighten(black, 25%); 20 | $dark-border-highlight : lighten(black, 55%); 21 | $dark-bg : lighten(black, 10%); 22 | $dark-text-main : lighten(black, 90%); 23 | $dark-text-info : lighten(black, 60%); 24 | $dark-uploader-bg : lighten(black, 13%); 25 | $dark-dialog-bg : lighten(black, 15%); 26 | $dark-uploader-bg-highlight : lighten(black, 18%); 27 | $dark-btn-bg : lighten(black, 20%); 28 | $dark-btn-bg-highlight : lighten(black, 30%); 29 | $dark-bg-th : lighten(black, 18%); 30 | $dark-bg-td : lighten(black, 13%); 31 | $dark-color-link : $green; 32 | 33 | $dark-blue : darken(desaturate($blue, 40%), 30%); 34 | $dark-red : darken(desaturate($red, 50%), 30%); 35 | $dark-green : darken(desaturate($green, 30%), 30%); 36 | -------------------------------------------------------------------------------- /src/utils/storage/BrowserNativeStorage.ts: -------------------------------------------------------------------------------- 1 | import BaseStorage, { KEY_PREFIX } from './BaseStorage'; 2 | 3 | export default class BrowserNativeStorage extends BaseStorage { 4 | public static get works() { 5 | return typeof localStorage !== 'undefined' && localStorage.getItem; 6 | } 7 | 8 | protected async load(name: string, defaultValue: T): Promise { 9 | const result = localStorage.getItem(name); 10 | if (result === null) { 11 | return defaultValue; 12 | } 13 | try { 14 | return JSON.parse(result); 15 | } catch { 16 | return defaultValue; 17 | } 18 | } 19 | 20 | protected async save(name: string, value: T): Promise { 21 | localStorage.setItem(name, JSON.stringify(value)); 22 | } 23 | 24 | public async getAll(): Promise> { 25 | const result = {}; 26 | for (const [key, value] of Object.entries(localStorage)) { 27 | if (key.startsWith(KEY_PREFIX)) { 28 | try { 29 | Object.assign(result, { [key]: JSON.parse(value) }); 30 | } catch { 31 | // ignored 32 | } 33 | } 34 | } 35 | return result; 36 | } 37 | 38 | public async setAll(obj: Record): Promise { 39 | for (const [key, value] of Object.entries(obj)) { 40 | await this.save(key, value); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/decrypt/joox.ts: -------------------------------------------------------------------------------- 1 | import jooxFactory from '@unlock-music/joox-crypto'; 2 | 3 | import { DecryptResult } from './entity'; 4 | import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from './utils'; 5 | 6 | import { MergeUint8Array } from '@/utils/MergeUint8Array'; 7 | import { storage } from '@/utils/storage'; 8 | import { extractQQMusicMeta } from '@/utils/qm_meta'; 9 | 10 | export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { 11 | const uuid = await storage.loadJooxUUID(''); 12 | if (!uuid || uuid.length !== 32) { 13 | throw new Error('请在“解密设定”填写应用 Joox 应用的 UUID。'); 14 | } 15 | 16 | const fileBuffer = new Uint8Array(await GetArrayBuffer(file)); 17 | const decryptor = jooxFactory(fileBuffer, uuid); 18 | if (!decryptor) { 19 | throw new Error('不支持的 joox 加密格式'); 20 | } 21 | 22 | const musicDecoded = MergeUint8Array(decryptor.decryptFile(fileBuffer)); 23 | const ext = SniffAudioExt(musicDecoded); 24 | const mime = AudioMimeType[ext]; 25 | 26 | const songId = raw_filename.match(/^(\d+)\s\[mqms\d*]$/i)?.[1]; 27 | const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( 28 | new Blob([musicDecoded], { type: mime }), 29 | raw_filename, 30 | ext, 31 | songId, 32 | ); 33 | 34 | return { 35 | title: title, 36 | artist: artist, 37 | ext: ext, 38 | album: album, 39 | picture: imgUrl, 40 | file: URL.createObjectURL(blob), 41 | blob: blob, 42 | mime: mime, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/shims-fs.d.ts: -------------------------------------------------------------------------------- 1 | export interface FileSystemGetFileOptions { 2 | create?: boolean; 3 | } 4 | 5 | interface FileSystemCreateWritableOptions { 6 | keepExistingData?: boolean; 7 | } 8 | 9 | interface FileSystemRemoveOptions { 10 | recursive?: boolean; 11 | } 12 | 13 | interface FileSystemFileHandle { 14 | getFile(): Promise; 15 | 16 | createWritable(options?: FileSystemCreateWritableOptions): Promise; 17 | } 18 | 19 | enum WriteCommandType { 20 | write = 'write', 21 | seek = 'seek', 22 | truncate = 'truncate', 23 | } 24 | 25 | interface WriteParams { 26 | type: WriteCommandType; 27 | size?: number; 28 | position?: number; 29 | data: BufferSource | Blob | string; 30 | } 31 | 32 | type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams; 33 | 34 | interface FileSystemWritableFileStream extends WritableStream { 35 | write(data: FileSystemWriteChunkType): Promise; 36 | 37 | seek(position: number): Promise; 38 | 39 | truncate(size: number): Promise; 40 | 41 | close(): Promise; // should be implemented in WritableStream 42 | } 43 | 44 | export declare interface FileSystemDirectoryHandle { 45 | getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise; 46 | 47 | removeEntry(name: string, options?: FileSystemRemoveOptions): Promise; 48 | } 49 | 50 | declare global { 51 | interface Window { 52 | showDirectoryPicker?(): Promise; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const ThreadsPlugin = require('threads-plugin'); 2 | module.exports = { 3 | publicPath: '', 4 | productionSourceMap: false, 5 | pwa: { 6 | manifestPath: "web-manifest.json", 7 | name: "音乐解锁", 8 | themeColor: "#4DBA87", 9 | msTileColor: "#000000", 10 | manifestOptions: { 11 | start_url: "./index.html", 12 | description: "在任何设备上解锁已购的加密音乐!", 13 | icons: [ 14 | { 15 | 'src': './img/icons/android-chrome-192x192.png', 16 | 'sizes': '192x192', 17 | 'type': 'image/png' 18 | }, 19 | { 20 | 'src': './img/icons/android-chrome-512x512.png', 21 | 'sizes': '512x512', 22 | 'type': 'image/png' 23 | } 24 | ] 25 | }, 26 | appleMobileWebAppCapable: 'yes', 27 | iconPaths: { 28 | faviconSVG: './img/icons/safari-pinned-tab.svg', 29 | favicon32: './img/icons/favicon-32x32.png', 30 | favicon16: './img/icons/favicon-16x16.png', 31 | appleTouchIcon: './img/icons/apple-touch-icon-152x152.png', 32 | maskIcon: './img/icons/safari-pinned-tab.svg', 33 | msTileImage: './img/icons/msapplication-icon-144x144.png' 34 | }, 35 | workboxPluginMode: "GenerateSW", 36 | workboxOptions: { 37 | skipWaiting: true 38 | } 39 | }, 40 | configureWebpack: { 41 | plugins: [new ThreadsPlugin()] 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/storage/ChromeExtensionStorage.ts: -------------------------------------------------------------------------------- 1 | import BaseStorage, { KEY_PREFIX } from './BaseStorage'; 2 | 3 | declare var chrome: any; 4 | 5 | export default class ChromeExtensionStorage extends BaseStorage { 6 | static get works(): boolean { 7 | return typeof chrome !== 'undefined' && Boolean(chrome?.storage?.local?.set); 8 | } 9 | 10 | protected async load(name: string, defaultValue: T): Promise { 11 | return new Promise((resolve) => { 12 | chrome.storage.local.get({ [name]: defaultValue }, (result: any) => { 13 | if (Object.prototype.hasOwnProperty.call(result, name)) { 14 | resolve(result[name]); 15 | } else { 16 | resolve(defaultValue); 17 | } 18 | }); 19 | }); 20 | } 21 | 22 | protected async save(name: string, value: T): Promise { 23 | return new Promise((resolve) => { 24 | chrome.storage.local.set({ [name]: value }, resolve); 25 | }); 26 | } 27 | 28 | public async getAll(): Promise> { 29 | return new Promise((resolve) => { 30 | chrome.storage.local.get(null, (obj: Record) => { 31 | const result: Record = {}; 32 | for (const [key, value] of Object.entries(obj)) { 33 | if (key.startsWith(KEY_PREFIX)) { 34 | result[key] = value; 35 | } 36 | } 37 | resolve(result); 38 | }); 39 | }); 40 | } 41 | 42 | public async setAll(obj: Record): Promise { 43 | return new Promise((resolve) => { 44 | chrome.storage.local.set(obj, resolve); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/scss/_utility.scss: -------------------------------------------------------------------------------- 1 | // box-shadow 2 | @mixin box-shadow($value...){ 3 | -webkit-box-shadow: $value; 4 | -moz-box-shadow: $value; 5 | box-shadow: $value; 6 | } 7 | 8 | // border-radius 9 | @mixin border-radius($corner...){ 10 | -webkit-border-radius: $corner; 11 | -moz-border-radius: $corner; 12 | border-radius: $corner; 13 | } 14 | 15 | @mixin clearfix(){ 16 | &:after{ 17 | content: ''; 18 | display: block; 19 | clear: both; 20 | visibility: hidden; 21 | } 22 | } 23 | 24 | @mixin transform($value){ 25 | -webkit-transform: $value; 26 | -moz-transform: $value; 27 | -ms-transform: $value; 28 | -o-transform: $value; 29 | transform: $value; 30 | } 31 | 32 | @mixin transition($value...){ 33 | -webkit-transition: $value; 34 | -moz-transition: $value; 35 | -ms-transition: $value; 36 | -o-transition: $value; 37 | transition: $value; 38 | } 39 | 40 | @mixin animation($value){ 41 | animation: $value; 42 | -webkit-animation: $value; 43 | } 44 | 45 | @mixin linear-gradient($direct, $colors){ 46 | background: linear-gradient($direct, $colors); 47 | background: -webkit-linear-gradient($direct, $colors); 48 | background: -moz-linear-gradient($direct, $colors); 49 | } 50 | 51 | @mixin backdrop-filter($value){ 52 | backdrop-filter: $value ; 53 | -webkit-backdrop-filter: $value; 54 | } 55 | 56 | 57 | /* 58 | Extension 59 | */ 60 | 61 | .unselectable { 62 | -webkit-user-select: none; 63 | -moz-user-select: none; 64 | -ms-user-select: none; 65 | user-select: none; 66 | } 67 | 68 | .btn-like{ 69 | &:active{ 70 | @include transform(translateY(2px)) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 7 | 8 | 9 | 11 | 13 | 15 | 17 | -------------------------------------------------------------------------------- /src/KgmWasm/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # CMakeList.txt : CMake project for KgmWasm, include source and define 2 | # project specific logic here. 3 | # 4 | cmake_minimum_required (VERSION 3.8) 5 | 6 | project ("KgmWasm") 7 | 8 | set(CMAKE_CXX_STANDARD 14) 9 | 10 | include_directories( 11 | $ 12 | ) 13 | 14 | # Add source to this project's executable. 15 | set(RUNTIME_METHODS_LIST 16 | getValue 17 | writeArrayToMemory 18 | UTF8ToString 19 | ) 20 | list(JOIN RUNTIME_METHODS_LIST "," RUNTIME_METHODS) 21 | 22 | set(EMSCRIPTEN_FLAGS 23 | "--bind" 24 | "-s NO_DYNAMIC_EXECUTION=1" 25 | "-s MODULARIZE=1" 26 | "-s EXPORT_NAME=KgmCryptoModule" 27 | "-s EXPORTED_RUNTIME_METHODS=${RUNTIME_METHODS}" 28 | ) 29 | set(EMSCRIPTEN_LEGACY_FLAGS 30 | ${EMSCRIPTEN_FLAGS} 31 | "-s WASM=0" 32 | "--memory-init-file 0" 33 | ) 34 | set(EMSCRIPTEN_WASM_BUNDLE_FLAGS 35 | ${EMSCRIPTEN_FLAGS} 36 | "-s SINGLE_FILE=1" 37 | ) 38 | 39 | list(JOIN EMSCRIPTEN_FLAGS " " EMSCRIPTEN_FLAGS_STR) 40 | list(JOIN EMSCRIPTEN_LEGACY_FLAGS " " EMSCRIPTEN_LEGACY_FLAGS_STR) 41 | list(JOIN EMSCRIPTEN_WASM_BUNDLE_FLAGS " " EMSCRIPTEN_WASM_BUNDLE_FLAGS_STR) 42 | 43 | # Define projects config 44 | set(WASM_SOURCES 45 | "KgmWasm.cpp" 46 | ) 47 | 48 | add_executable(KgmWasm ${WASM_SOURCES}) 49 | set_target_properties( 50 | KgmWasm 51 | PROPERTIES LINK_FLAGS ${EMSCRIPTEN_FLAGS_STR} 52 | ) 53 | 54 | add_executable(KgmWasmBundle ${WASM_SOURCES}) 55 | set_target_properties( 56 | KgmWasmBundle 57 | PROPERTIES LINK_FLAGS ${EMSCRIPTEN_WASM_BUNDLE_FLAGS_STR} 58 | ) 59 | 60 | add_executable(KgmLegacy ${WASM_SOURCES}) 61 | set_target_properties( 62 | KgmLegacy 63 | PROPERTIES LINK_FLAGS ${EMSCRIPTEN_LEGACY_FLAGS_STR} 64 | ) 65 | 66 | -------------------------------------------------------------------------------- /src/QmcWasm/CMakeLists.txt: -------------------------------------------------------------------------------- 1 | # CMakeList.txt : CMake project for QmcWasm, include source and define 2 | # project specific logic here. 3 | # 4 | cmake_minimum_required (VERSION 3.8) 5 | 6 | project ("QmcWasm") 7 | 8 | set(CMAKE_CXX_STANDARD 14) 9 | 10 | include_directories( 11 | $ 12 | ) 13 | 14 | # Add source to this project's executable. 15 | set(RUNTIME_METHODS_LIST 16 | getValue 17 | writeArrayToMemory 18 | UTF8ToString 19 | ) 20 | list(JOIN RUNTIME_METHODS_LIST "," RUNTIME_METHODS) 21 | 22 | set(EMSCRIPTEN_FLAGS 23 | "--bind" 24 | "-s NO_DYNAMIC_EXECUTION=1" 25 | "-s MODULARIZE=1" 26 | "-s EXPORT_NAME=QmcCryptoModule" 27 | "-s EXPORTED_RUNTIME_METHODS=${RUNTIME_METHODS}" 28 | ) 29 | set(EMSCRIPTEN_LEGACY_FLAGS 30 | ${EMSCRIPTEN_FLAGS} 31 | "-s WASM=0" 32 | "--memory-init-file 0" 33 | ) 34 | set(EMSCRIPTEN_WASM_BUNDLE_FLAGS 35 | ${EMSCRIPTEN_FLAGS} 36 | "-s SINGLE_FILE=1" 37 | ) 38 | 39 | list(JOIN EMSCRIPTEN_FLAGS " " EMSCRIPTEN_FLAGS_STR) 40 | list(JOIN EMSCRIPTEN_LEGACY_FLAGS " " EMSCRIPTEN_LEGACY_FLAGS_STR) 41 | list(JOIN EMSCRIPTEN_WASM_BUNDLE_FLAGS " " EMSCRIPTEN_WASM_BUNDLE_FLAGS_STR) 42 | 43 | # Define projects config 44 | set(WASM_SOURCES 45 | "QmcWasm.cpp" 46 | ) 47 | 48 | add_executable(QmcWasm ${WASM_SOURCES}) 49 | set_target_properties( 50 | QmcWasm 51 | PROPERTIES LINK_FLAGS ${EMSCRIPTEN_FLAGS_STR} 52 | ) 53 | 54 | add_executable(QmcWasmBundle ${WASM_SOURCES}) 55 | set_target_properties( 56 | QmcWasmBundle 57 | PROPERTIES LINK_FLAGS ${EMSCRIPTEN_WASM_BUNDLE_FLAGS_STR} 58 | ) 59 | 60 | add_executable(QmcLegacy ${WASM_SOURCES}) 61 | set_target_properties( 62 | QmcLegacy 63 | PROPERTIES LINK_FLAGS ${EMSCRIPTEN_LEGACY_FLAGS_STR} 64 | ) 65 | 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unlock-music", 3 | "version": "1.10.3", 4 | "ext_build": 0, 5 | "updateInfo": "完善音乐标签编辑功能,支持编辑更多标签", 6 | "license": "MIT", 7 | "description": "Unlock encrypted music file in browser.", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/ix64/unlock-music" 11 | }, 12 | "private": true, 13 | "scripts": { 14 | "postinstall": "patch-package", 15 | "serve": "vue-cli-service serve", 16 | "build": "vue-cli-service build", 17 | "test": "jest", 18 | "pretty": "prettier --write src/{**/*,*}.{js,ts,jsx,tsx,vue}", 19 | "pretty:check": "prettier --check src/{**/*,*}.{js,ts,jsx,tsx,vue}", 20 | "make-extension": "node ./make-extension.js" 21 | }, 22 | "dependencies": { 23 | "@babel/preset-typescript": "^7.16.5", 24 | "@jixun/kugou-crypto": "^1.0.3", 25 | "@unlock-music/joox-crypto": "^0.0.1-R5", 26 | "base64-js": "^1.5.1", 27 | "browser-id3-writer": "^4.4.0", 28 | "core-js": "^3.16.0", 29 | "crypto-js": "^4.1.1", 30 | "element-ui": "^2.15.5", 31 | "iconv-lite": "^0.6.3", 32 | "jimp": "^0.16.1", 33 | "metaflac-js": "^1.0.5", 34 | "music-metadata": "7.9.0", 35 | "music-metadata-browser": "2.2.7", 36 | "register-service-worker": "^1.7.2", 37 | "threads": "^1.6.5", 38 | "vue": "^2.6.14" 39 | }, 40 | "devDependencies": { 41 | "@types/crypto-js": "^4.0.2", 42 | "@types/jest": "^27.0.3", 43 | "@vue/cli-plugin-babel": "^4.5.13", 44 | "@vue/cli-plugin-pwa": "^4.5.13", 45 | "@vue/cli-plugin-typescript": "^4.5.13", 46 | "@vue/cli-service": "^4.5.13", 47 | "babel-plugin-component": "^1.1.1", 48 | "jest": "^27.4.5", 49 | "patch-package": "^6.4.7", 50 | "prettier": "2.5.1", 51 | "sass": "^1.38.1", 52 | "sass-loader": "^10.2.0", 53 | "semver": "^7.3.5", 54 | "threads-plugin": "^1.4.0", 55 | "typescript": "^4.5.4", 56 | "vue-cli-plugin-element": "^1.0.1", 57 | "vue-template-compiler": "^2.6.14" 58 | } 59 | } -------------------------------------------------------------------------------- /src/decrypt/kgm_wasm.ts: -------------------------------------------------------------------------------- 1 | import KgmCryptoModule from '@/KgmWasm/KgmWasmBundle'; 2 | import { MergeUint8Array } from '@/utils/MergeUint8Array'; 3 | 4 | // 每次处理 2M 的数据 5 | const DECRYPTION_BUF_SIZE = 2 *1024 * 1024; 6 | 7 | export interface KGMDecryptionResult { 8 | success: boolean; 9 | data: Uint8Array; 10 | error: string; 11 | } 12 | 13 | /** 14 | * 解密一个 KGM 加密的文件。 15 | * 16 | * 如果检测并解密成功,返回解密后的 Uint8Array 数据。 17 | * @param {ArrayBuffer} kgmBlob 读入的文件 Blob 18 | */ 19 | export async function DecryptKgmWasm(kgmBlob: ArrayBuffer, ext: string): Promise { 20 | const result: KGMDecryptionResult = { success: false, data: new Uint8Array(), error: '' }; 21 | 22 | // 初始化模组 23 | let KgmCrypto: any; 24 | 25 | try { 26 | KgmCrypto = await KgmCryptoModule(); 27 | } catch (err: any) { 28 | result.error = err?.message || 'wasm 加载失败'; 29 | return result; 30 | } 31 | if (!KgmCrypto) { 32 | result.error = 'wasm 加载失败'; 33 | return result; 34 | } 35 | 36 | // 申请内存块,并文件末端数据到 WASM 的内存堆 37 | let kgmBuf = new Uint8Array(kgmBlob); 38 | const pQmcBuf = KgmCrypto._malloc(DECRYPTION_BUF_SIZE); 39 | KgmCrypto.writeArrayToMemory(kgmBuf.slice(0, DECRYPTION_BUF_SIZE), pQmcBuf); 40 | 41 | // 进行解密初始化 42 | const headerSize = KgmCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext); 43 | console.log(headerSize); 44 | kgmBuf = kgmBuf.slice(headerSize); 45 | 46 | const decryptedParts = []; 47 | let offset = 0; 48 | let bytesToDecrypt = kgmBuf.length; 49 | while (bytesToDecrypt > 0) { 50 | const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); 51 | 52 | // 解密一些片段 53 | const blockData = new Uint8Array(kgmBuf.slice(offset, offset + blockSize)); 54 | KgmCrypto.writeArrayToMemory(blockData, pQmcBuf); 55 | KgmCrypto.decBlob(pQmcBuf, blockSize, offset); 56 | decryptedParts.push(KgmCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + blockSize)); 57 | 58 | offset += blockSize; 59 | bytesToDecrypt -= blockSize; 60 | } 61 | KgmCrypto._free(pQmcBuf); 62 | 63 | result.data = MergeUint8Array(decryptedParts); 64 | result.success = true; 65 | 66 | return result; 67 | } 68 | -------------------------------------------------------------------------------- /src/decrypt/__test__/joox.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { storage } from '@/utils/storage'; 3 | 4 | import { Decrypt as decryptJoox } from '../joox'; 5 | import { extractQQMusicMeta as extractQQMusicMetaOrig } from '@/utils/qm_meta'; 6 | 7 | jest.mock('@/utils/storage'); 8 | jest.mock('@/utils/qm_meta'); 9 | 10 | const loadJooxUUID = storage.loadJooxUUID as jest.MockedFunction; 11 | const extractQQMusicMeta = extractQQMusicMetaOrig as jest.MockedFunction; 12 | 13 | const TEST_UUID_ZEROS = ''.padStart(32, '0'); 14 | const encryptedFile1 = fs.readFileSync(__dirname + '/fixture/joox_1.bin'); 15 | 16 | describe('decrypt/joox', () => { 17 | it('should be able to decrypt sample file (v4)', async () => { 18 | loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS); 19 | extractQQMusicMeta.mockImplementationOnce(async (blob: Blob) => { 20 | return { 21 | title: 'unused', 22 | album: 'unused', 23 | blob: blob, 24 | artist: 'unused', 25 | imgUrl: 'https://github.com/unlock-music', 26 | }; 27 | }); 28 | 29 | const result = await decryptJoox(new Blob([encryptedFile1]), 'test.bin', 'bin'); 30 | const resultBuf = await result.blob.arrayBuffer(); 31 | expect(resultBuf).toEqual(Buffer.from('Hello World', 'utf-8').buffer); 32 | }); 33 | 34 | it('should reject E!99 files', async () => { 35 | loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS); 36 | 37 | const input = new Blob([Buffer.from('E!99....')]); 38 | await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('不支持的 joox 加密格式'); 39 | }); 40 | 41 | it('should reject empty uuid', async () => { 42 | loadJooxUUID.mockResolvedValue(''); 43 | const input = new Blob([encryptedFile1]); 44 | await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID'); 45 | }); 46 | 47 | it('should reject invalid uuid', async () => { 48 | loadJooxUUID.mockResolvedValue('hello!'); 49 | const input = new Blob([encryptedFile1]); 50 | await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID'); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/component/PreviewTable.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 音乐解锁 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | 22 |

请勿直接运行源代码!

23 | 28 | 35 |
36 |
37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /src/decrypt/qmc_wasm.ts: -------------------------------------------------------------------------------- 1 | import QmcCryptoModule from '@/QmcWasm/QmcWasmBundle'; 2 | import { MergeUint8Array } from '@/utils/MergeUint8Array'; 3 | 4 | // 每次处理 2M 的数据 5 | const DECRYPTION_BUF_SIZE = 2 *1024 * 1024; 6 | 7 | export interface QMCDecryptionResult { 8 | success: boolean; 9 | data: Uint8Array; 10 | songId: string | number; 11 | error: string; 12 | } 13 | 14 | /** 15 | * 解密一个 QMC 加密的文件。 16 | * 17 | * 如果检测并解密成功,返回解密后的 Uint8Array 数据。 18 | * @param {ArrayBuffer} qmcBlob 读入的文件 Blob 19 | */ 20 | export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise { 21 | const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' }; 22 | 23 | // 初始化模组 24 | let QmcCrypto: any; 25 | 26 | try { 27 | QmcCrypto = await QmcCryptoModule(); 28 | } catch (err: any) { 29 | result.error = err?.message || 'wasm 加载失败'; 30 | return result; 31 | } 32 | if (!QmcCrypto) { 33 | result.error = 'wasm 加载失败'; 34 | return result; 35 | } 36 | 37 | // 申请内存块,并文件末端数据到 WASM 的内存堆 38 | const qmcBuf = new Uint8Array(qmcBlob); 39 | const pQmcBuf = QmcCrypto._malloc(DECRYPTION_BUF_SIZE); 40 | QmcCrypto.writeArrayToMemory(qmcBuf.slice(-DECRYPTION_BUF_SIZE), pQmcBuf); 41 | 42 | // 进行解密初始化 43 | ext = '.' + ext; 44 | const tailSize = QmcCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext); 45 | if (tailSize == -1) { 46 | result.error = QmcCrypto.getError(); 47 | return result; 48 | } else { 49 | result.songId = QmcCrypto.getSongId(); 50 | result.songId = result.songId == "0" ? 0 : result.songId; 51 | } 52 | 53 | const decryptedParts = []; 54 | let offset = 0; 55 | let bytesToDecrypt = qmcBuf.length - tailSize; 56 | while (bytesToDecrypt > 0) { 57 | const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); 58 | 59 | // 解密一些片段 60 | const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize)); 61 | QmcCrypto.writeArrayToMemory(blockData, pQmcBuf); 62 | decryptedParts.push(QmcCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCrypto.decBlob(pQmcBuf, blockSize, offset))); 63 | 64 | offset += blockSize; 65 | bytesToDecrypt -= blockSize; 66 | } 67 | QmcCrypto._free(pQmcBuf); 68 | 69 | result.data = MergeUint8Array(decryptedParts); 70 | result.success = true; 71 | 72 | return result; 73 | } 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 关于仓库官方 2 | 3 | 本仓库原始地址(已 DMCA):https://github.com/unlock-music/unlock-music 4 | 5 | 本仓库目前官方地址:https://git.unlock-music.dev/um/web 6 | 7 | 你所看到的这个仓库是依照 MIT 协议授权转载的,代码与[本人](https://github.com/ipid)无关。 8 | 9 | # Unlock Music 音乐解锁 10 | 11 | [![Build Status](https://ci.unlock-music.dev/api/badges/um/web/status.svg)](https://ci.unlock-music.dev/um/web) 12 | 13 | - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. 14 | - Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。 15 | - Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。 16 | - 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入! 17 | - CI 自动构建已经部署,可以在 [UM-Packages] 下载 18 | 19 | [授权协议]: https://git.unlock-music.dev/um/web/src/branch/master/LICENSE 20 | [unlock-music/cli]: https://git.unlock-music.dev/um/cli 21 | [`@unlock_music_chat`]: https://t.me/unlock_music_chat 22 | [UM-Packages]: https://git.unlock-music.dev/um/-/packages/generic/web-build/ 23 | 24 | ## 特性 25 | 26 | ### 支持的格式 27 | 28 | - [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm) 29 | - [x] Moo 音乐格式 (.bkcmp3/.bkcflac/...) 30 | - [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6) 31 | - [x] QQ 音乐新格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl) 32 | - [x] QQ 音乐海外版JOOX Music (.ofl_en) 33 | - [x] 网易云音乐格式 (.ncm) 34 | - [x] 虾米音乐格式 (.xm) 35 | - [x] 酷我音乐格式 (.kwm) 36 | - [x] 酷狗音乐格式 (.kgm/.vpr) 37 | - [x] Android版喜马拉雅文件格式 (.x2m/.x3m) 38 | - [x] 咪咕音乐格式 (.mg3d) 39 | 40 | ### 其他特性 41 | 42 | - [x] 在浏览器中解锁 43 | - [x] 拖放文件 44 | - [x] 批量解锁 45 | - [x] 渐进式 Web 应用 (PWA) 46 | - [x] 多线程 47 | - [x] 写入和编辑元信息与专辑封面 48 | 49 | ## 使用方法 50 | 51 | ### 使用预构建版本 52 | 53 | - 从 [Release] 或 [CI 构建][UM-Packages] 下载预构建的版本 54 | - :warning: 本地使用请下载`legacy版本`(`modern版本`只能通过 **http(s)协议** 访问) 55 | - 解压缩后即可部署或本地使用(**请勿直接运行源代码**) 56 | 57 | [release]: https://git.unlock-music.dev/um/web/releases/latest 58 | 59 | ### 自行构建 60 | 61 | #### JS部分 62 | 63 | - 环境要求 64 | - nodejs (v16.x) 65 | - npm 66 | 67 | 1. 获取项目源代码后安装相关依赖: 68 | 69 | ```sh 70 | npm ci 71 | ``` 72 | 73 | 2. 然后进行构建: 74 | 75 | ```sh 76 | npm run build 77 | ``` 78 | 79 | - 构建后的产物可以在 `dist` 目录找到。 80 | - 如果是用于开发,可以执行 `npm run serve`。 81 | 82 | 3. 如需构建浏览器扩展,构建成功后还需要执行: 83 | 84 | ```sh 85 | npm run make-extension 86 | ``` 87 | 88 | #### WASM部分 89 | 90 | - 环境要求 91 | - Linux 92 | - python3 93 | 94 | - 运行此目录下的build-wasm 95 | 96 | ```sh 97 | ./scripts/build-wasm.sh 98 | ``` 99 | -------------------------------------------------------------------------------- /src/decrypt/qmccache.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioMimeType, 3 | GetArrayBuffer, 4 | GetCoverFromFile, 5 | GetMetaFromFile, 6 | SniffAudioExt, 7 | SplitFilename, 8 | } from '@/decrypt/utils'; 9 | 10 | import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc'; 11 | import { DecryptQmcWasm } from '@/decrypt/qmc_wasm'; 12 | 13 | import { DecryptResult } from '@/decrypt/entity'; 14 | 15 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 16 | 17 | export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { 18 | const buffer = await GetArrayBuffer(file); 19 | 20 | let musicDecoded: Uint8Array | undefined; 21 | if (globalThis.WebAssembly) { 22 | console.log('qmc: using wasm decoder'); 23 | 24 | const qmcDecrypted = await DecryptQmcWasm(buffer, raw_ext); 25 | // 若 wasm 失败,使用 js 再尝试一次 26 | if (qmcDecrypted.success) { 27 | musicDecoded = qmcDecrypted.data; 28 | console.log('qmc wasm decoder suceeded'); 29 | } else { 30 | console.warn('QmcWasm failed with error %s', qmcDecrypted.error || '(unknown error)'); 31 | } 32 | } 33 | 34 | if (!musicDecoded) { 35 | musicDecoded = new Uint8Array(buffer); 36 | let length = musicDecoded.length; 37 | for (let i = 0; i < length; i++) { 38 | let byte = musicDecoded[i] ^ 0xf4; // xor 0xf4 39 | byte = ((byte & 0b0011_1111) << 2) | (byte >> 6); // rol 2 40 | musicDecoded[i] = byte; 41 | } 42 | } 43 | let ext = SniffAudioExt(musicDecoded, ''); 44 | const newName = SplitFilename(raw_filename); 45 | let audioBlob: Blob; 46 | if (ext !== '' || newName.ext === 'mp3') { 47 | audioBlob = new Blob([musicDecoded], { type: AudioMimeType[ext] }); 48 | } else if (newName.ext in HandlerMap) { 49 | audioBlob = new Blob([musicDecoded], { type: 'application/octet-stream' }); 50 | return QmcDecrypt(audioBlob, newName.name, newName.ext); 51 | } else { 52 | throw '不支持的QQ音乐缓存格式'; 53 | } 54 | const tag = await metaParseBlob(audioBlob); 55 | const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || "")); 56 | 57 | return { 58 | title, 59 | artist, 60 | ext, 61 | album: tag.common.album, 62 | picture: GetCoverFromFile(tag), 63 | file: URL.createObjectURL(audioBlob), 64 | blob: audioBlob, 65 | mime: AudioMimeType[ext], 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/decrypt/xm.ts: -------------------------------------------------------------------------------- 1 | import { Decrypt as RawDecrypt } from '@/decrypt/raw'; 2 | import { DecryptResult } from '@/decrypt/entity'; 3 | import { AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile } from '@/decrypt/utils'; 4 | 5 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 6 | 7 | const MagicHeader = [0x69, 0x66, 0x6d, 0x74]; 8 | const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]; 9 | const FileTypeMap: { [key: string]: string } = { 10 | ' WAV': '.wav', 11 | FLAC: '.flac', 12 | ' MP3': '.mp3', 13 | ' A4M': '.m4a', 14 | }; 15 | 16 | export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise { 17 | const oriData = new Uint8Array(await GetArrayBuffer(file)); 18 | if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) { 19 | if (raw_ext === 'xm') { 20 | throw Error('此xm文件已损坏'); 21 | } else { 22 | return await RawDecrypt(file, raw_filename, raw_ext, true); 23 | } 24 | } 25 | 26 | let typeText = new TextDecoder().decode(oriData.slice(4, 8)); 27 | if (!FileTypeMap.hasOwnProperty(typeText)) { 28 | throw Error('未知的.xm文件类型'); 29 | } 30 | 31 | let key = oriData[0xf]; 32 | let dataOffset = oriData[0xc] | (oriData[0xd] << 8) | (oriData[0xe] << 16); 33 | let audioData = oriData.slice(0x10); 34 | let lenAudioData = audioData.length; 35 | for (let cur = dataOffset; cur < lenAudioData; ++cur) audioData[cur] = (audioData[cur] - key) ^ 0xff; 36 | 37 | const ext = FileTypeMap[typeText]; 38 | const mime = AudioMimeType[ext]; 39 | let musicBlob = new Blob([audioData], { type: mime }); 40 | 41 | const musicMeta = await metaParseBlob(musicBlob); 42 | if (ext === 'wav') { 43 | //todo:未知的编码方式 44 | console.info(musicMeta.common); 45 | musicMeta.common.album = ''; 46 | musicMeta.common.artist = ''; 47 | musicMeta.common.title = ''; 48 | } 49 | const { title, artist } = GetMetaFromFile( 50 | raw_filename, 51 | musicMeta.common.title, 52 | String(musicMeta.common.artists || musicMeta.common.artist || ""), 53 | raw_filename.indexOf('_') === -1 ? '-' : '_', 54 | ); 55 | 56 | return { 57 | title, 58 | artist, 59 | ext, 60 | mime, 61 | album: musicMeta.common.album, 62 | picture: GetCoverFromFile(musicMeta), 63 | file: URL.createObjectURL(musicBlob), 64 | blob: musicBlob, 65 | rawExt: 'xm', 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import { DecryptResult } from '@/decrypt/entity'; 2 | import { FileSystemDirectoryHandle } from '@/shims-fs'; 3 | 4 | export enum FilenamePolicy { 5 | ArtistAndTitle, 6 | TitleOnly, 7 | TitleAndArtist, 8 | SameAsOriginal, 9 | } 10 | 11 | export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [ 12 | { key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' }, 13 | { key: FilenamePolicy.TitleOnly, text: '歌曲名' }, 14 | { key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' }, 15 | { key: FilenamePolicy.SameAsOriginal, text: '同源文件名' }, 16 | ]; 17 | 18 | export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string { 19 | switch (policy) { 20 | case FilenamePolicy.TitleOnly: 21 | return `${data.title}.${data.ext}`; 22 | case FilenamePolicy.TitleAndArtist: 23 | return `${data.title} - ${data.artist}.${data.ext}`; 24 | case FilenamePolicy.SameAsOriginal: 25 | return `${data.rawFilename}.${data.ext}`; 26 | default: 27 | case FilenamePolicy.ArtistAndTitle: 28 | return `${data.artist} - ${data.title}.${data.ext}`; 29 | } 30 | } 31 | 32 | export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) { 33 | let filename = GetDownloadFilename(data, policy); 34 | // prevent filename exist 35 | try { 36 | await dir.getFileHandle(filename); 37 | filename = `${new Date().getTime()} - ${filename}`; 38 | } catch (e) {} 39 | const file = await dir.getFileHandle(filename, { create: true }); 40 | const w = await file.createWritable(); 41 | await w.write(data.blob); 42 | await w.close(); 43 | } 44 | 45 | export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) { 46 | const a = document.createElement('a'); 47 | a.href = data.file; 48 | a.download = GetDownloadFilename(data, policy); 49 | document.body.append(a); 50 | a.click(); 51 | a.remove(); 52 | } 53 | 54 | export function RemoveBlobMusic(data: DecryptResult) { 55 | URL.revokeObjectURL(data.file); 56 | if (data.picture?.startsWith('blob:')) { 57 | URL.revokeObjectURL(data.picture); 58 | } 59 | } 60 | 61 | export class DecryptQueue { 62 | private readonly pending: (() => Promise)[]; 63 | 64 | constructor() { 65 | this.pending = []; 66 | } 67 | 68 | queue(fn: () => Promise) { 69 | this.pending.push(fn); 70 | this.consume(); 71 | } 72 | 73 | private consume() { 74 | const fn = this.pending.shift(); 75 | if (fn) 76 | fn() 77 | .then(() => this.consume) 78 | .catch(console.error); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/utils/tea.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 MengYX. All rights reserved. 2 | // 3 | // Copyright 2015 The Go Authors. All rights reserved. 4 | // Use of this source code is governed by a BSD-style 5 | // license that can be found in https://go.dev/LICENSE. 6 | 7 | import { TeaCipher } from '@/utils/tea'; 8 | 9 | test('key size', () => { 10 | // prettier-ignore 11 | const testKey = new Uint8Array([ 12 | 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 13 | 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 14 | 0x00, 15 | ]) 16 | expect(() => new TeaCipher(testKey.slice(0, 16))).not.toThrow(); 17 | 18 | expect(() => new TeaCipher(testKey)).toThrow(); 19 | 20 | expect(() => new TeaCipher(testKey.slice(0, 15))).toThrow(); 21 | }); 22 | 23 | // prettier-ignore 24 | const teaTests = [ 25 | // These were sourced from https://github.com/froydnj/ironclad/blob/master/testing/test-vectors/tea.testvec 26 | { 27 | rounds: TeaCipher.numRounds, 28 | key: new Uint8Array([ 29 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 30 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 31 | ]), 32 | plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), 33 | cipherText: new Uint8Array([0x41, 0xea, 0x3a, 0x0a, 0x94, 0xba, 0xa9, 0x40]), 34 | }, 35 | { 36 | rounds: TeaCipher.numRounds, 37 | key: new Uint8Array([ 38 | 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 39 | 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 40 | ]), 41 | plainText: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]), 42 | cipherText: new Uint8Array([0x31, 0x9b, 0xbe, 0xfb, 0x01, 0x6a, 0xbd, 0xb2]), 43 | }, 44 | { 45 | rounds: 16, 46 | key: new Uint8Array([ 47 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 48 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 49 | ]), 50 | plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), 51 | cipherText: new Uint8Array([0xed, 0x28, 0x5d, 0xa1, 0x45, 0x5b, 0x33, 0xc1]), 52 | }, 53 | ]; 54 | 55 | test('rounds', () => { 56 | const tt = teaTests[0]; 57 | expect(() => new TeaCipher(tt.key, tt.rounds - 1)).toThrow(); 58 | }); 59 | 60 | test('encrypt & decrypt', () => { 61 | for (const tt of teaTests) { 62 | const c = new TeaCipher(tt.key, tt.rounds); 63 | 64 | const buf = new Uint8Array(8); 65 | const bufView = new DataView(buf.buffer); 66 | 67 | c.encrypt(bufView, new DataView(tt.plainText.buffer)); 68 | expect(buf).toStrictEqual(tt.cipherText); 69 | 70 | c.decrypt(bufView, new DataView(tt.cipherText.buffer)); 71 | expect(buf).toStrictEqual(tt.plainText); 72 | } 73 | }); 74 | -------------------------------------------------------------------------------- /src/decrypt/kwm.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioMimeType, 3 | BytesHasPrefix, 4 | GetArrayBuffer, 5 | GetCoverFromFile, 6 | GetMetaFromFile, 7 | SniffAudioExt, 8 | } from '@/decrypt/utils'; 9 | import { Decrypt as RawDecrypt } from '@/decrypt/raw'; 10 | 11 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 12 | import { DecryptResult } from '@/decrypt/entity'; 13 | 14 | //prettier-ignore 15 | const MagicHeader = [ 16 | 0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D, 17 | 0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65, 18 | ]; 19 | const MagicHeader2 = [ 20 | 0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D, 21 | 0x6B, 0x75, 0x77, 0x6F, 0x00, 0x00, 0x00, 0x00, 22 | ]; 23 | const PreDefinedKey = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk'; 24 | 25 | export async function Decrypt(file: File, raw_filename: string, _: string): Promise { 26 | const oriData = new Uint8Array(await GetArrayBuffer(file)); 27 | if (!BytesHasPrefix(oriData, MagicHeader) && !BytesHasPrefix(oriData, MagicHeader2)) { 28 | if (SniffAudioExt(oriData) === 'aac') { 29 | return await RawDecrypt(file, raw_filename, 'aac', false); 30 | } 31 | throw Error('not a valid kwm file'); 32 | } 33 | 34 | let fileKey = oriData.slice(0x18, 0x20); 35 | let mask = createMaskFromKey(fileKey); 36 | let audioData = oriData.slice(0x400); 37 | let lenAudioData = audioData.length; 38 | for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20]; 39 | 40 | const ext = SniffAudioExt(audioData); 41 | const mime = AudioMimeType[ext]; 42 | let musicBlob = new Blob([audioData], { type: mime }); 43 | 44 | const musicMeta = await metaParseBlob(musicBlob); 45 | const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || "")); 46 | return { 47 | album: musicMeta.common.album, 48 | picture: GetCoverFromFile(musicMeta), 49 | file: URL.createObjectURL(musicBlob), 50 | blob: musicBlob, 51 | mime, 52 | title, 53 | artist, 54 | ext, 55 | }; 56 | } 57 | 58 | function createMaskFromKey(keyBytes: Uint8Array): Uint8Array { 59 | let keyView = new DataView(keyBytes.buffer); 60 | let keyStr = keyView.getBigUint64(0, true).toString(); 61 | let keyStrTrim = trimKey(keyStr); 62 | let key = new Uint8Array(32); 63 | for (let i = 0; i < 32; i++) { 64 | key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i); 65 | } 66 | return key; 67 | } 68 | 69 | function trimKey(keyRaw: string): string { 70 | let lenRaw = keyRaw.length; 71 | let out = keyRaw; 72 | if (lenRaw > 32) { 73 | out = keyRaw.slice(0, 32); 74 | } else if (lenRaw < 32) { 75 | out = keyRaw.padEnd(32, keyRaw); 76 | } 77 | return out; 78 | } 79 | -------------------------------------------------------------------------------- /src/decrypt/mg3d.ts: -------------------------------------------------------------------------------- 1 | import { Decrypt as RawDecrypt } from './raw'; 2 | import { GetArrayBuffer } from '@/decrypt/utils'; 3 | import { DecryptResult } from '@/decrypt/entity'; 4 | 5 | const segmentSize = 0x20; 6 | 7 | function isPrintableAsciiChar(ch: number) { 8 | return ch >= 0x20 && ch <= 0x7E; 9 | } 10 | 11 | function isUpperHexChar(ch: number) { 12 | return (ch >= 0x30 && ch <= 0x39) || (ch >= 0x41 && ch <= 0x46); 13 | } 14 | 15 | /** 16 | * @param {Buffer} data 17 | * @param {Buffer} key 18 | * @param {boolean} copy 19 | * @returns Buffer 20 | */ 21 | function decryptSegment(data: Uint8Array, key: Uint8Array) { 22 | for (let i = 0; i < data.byteLength; i++) { 23 | data[i] -= key[i % segmentSize]; 24 | } 25 | return Buffer.from(data); 26 | } 27 | 28 | export async function Decrypt(file: File, raw_filename: string): Promise { 29 | const buf = new Uint8Array(await GetArrayBuffer(file)); 30 | 31 | // 咪咕编码的 WAV 文件有很多“空洞”内容,尝试密钥。 32 | const header = buf.slice(0, 0x100); 33 | const bytesRIFF = Buffer.from('RIFF', 'ascii'); 34 | const bytesWaveFormat = Buffer.from('WAVEfmt ', 'ascii'); 35 | const possibleKeys = []; 36 | 37 | for (let i = segmentSize; i < segmentSize * 20; i += segmentSize) { 38 | const possibleKey = buf.slice(i, i + segmentSize); 39 | if (!possibleKey.every(isUpperHexChar)) continue; 40 | 41 | const tempHeader = decryptSegment(header, possibleKey); 42 | if (tempHeader.slice(0, 4).compare(bytesRIFF)) continue; 43 | if (tempHeader.slice(8, 16).compare(bytesWaveFormat)) continue; 44 | 45 | // fmt chunk 大小可以是 16 / 18 / 40。 46 | const fmtChunkSize = tempHeader.readUInt32LE(0x10); 47 | if (![16, 18, 40].includes(fmtChunkSize)) continue; 48 | 49 | // 下一个 chunk 50 | const firstDataChunkOffset = 0x14 + fmtChunkSize; 51 | const chunkName = tempHeader.slice(firstDataChunkOffset, firstDataChunkOffset + 4); 52 | if (!chunkName.every(isPrintableAsciiChar)) continue; 53 | 54 | const secondDataChunkOffset = firstDataChunkOffset + 8 + tempHeader.readUInt32LE(firstDataChunkOffset + 4); 55 | if (secondDataChunkOffset <= header.byteLength) { 56 | const secondChunkName = tempHeader.slice(secondDataChunkOffset, secondDataChunkOffset + 4); 57 | if (!secondChunkName.every(isPrintableAsciiChar)) continue; 58 | } 59 | 60 | possibleKeys.push(Buffer.from(possibleKey).toString('ascii')); 61 | } 62 | 63 | if (possibleKeys.length <= 0) { 64 | throw new Error(`ERROR: no suitable key discovered`); 65 | } 66 | 67 | const decryptionKey = Buffer.from(possibleKeys[0], 'ascii'); 68 | decryptSegment(buf, decryptionKey); 69 | const musicData = new Blob([buf], { type: 'audio/x-wav' }); 70 | return await RawDecrypt(musicData, raw_filename, 'wav', false); 71 | } 72 | -------------------------------------------------------------------------------- /src/utils/tea.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 MengYX. All rights reserved. 2 | // 3 | // Copyright 2015 The Go Authors. All rights reserved. 4 | // Use of this source code is governed by a BSD-style 5 | // license that can be found in https://go.dev/LICENSE. 6 | 7 | // TeaCipher is a typescript port to golang.org/x/crypto/tea 8 | 9 | // Package tea implements the TEA algorithm, as defined in Needham and 10 | // Wheeler's 1994 technical report, “TEA, a Tiny Encryption Algorithm”. See 11 | // http://www.cix.co.uk/~klockstone/tea.pdf for details. 12 | // 13 | // TEA is a legacy cipher and its short block size makes it vulnerable to 14 | // birthday bound attacks (see https://sweet32.info). It should only be used 15 | // where compatibility with legacy systems, not security, is the goal. 16 | 17 | export class TeaCipher { 18 | // BlockSize is the size of a TEA block, in bytes. 19 | static readonly BlockSize = 8; 20 | 21 | // KeySize is the size of a TEA key, in bytes. 22 | static readonly KeySize = 16; 23 | 24 | // delta is the TEA key schedule constant. 25 | static readonly delta = 0x9e3779b9; 26 | 27 | // numRounds 64 is the standard number of rounds in TEA. 28 | static readonly numRounds = 64; 29 | 30 | k0: number; 31 | k1: number; 32 | k2: number; 33 | k3: number; 34 | rounds: number; 35 | 36 | constructor(key: Uint8Array, rounds: number = TeaCipher.numRounds) { 37 | if (key.length != 16) { 38 | throw Error('incorrect key size'); 39 | } 40 | if ((rounds & 1) != 0) { 41 | throw Error('odd number of rounds specified'); 42 | } 43 | 44 | const k = new DataView(key.buffer); 45 | this.k0 = k.getUint32(0, false); 46 | this.k1 = k.getUint32(4, false); 47 | this.k2 = k.getUint32(8, false); 48 | this.k3 = k.getUint32(12, false); 49 | this.rounds = rounds; 50 | } 51 | 52 | encrypt(dst: DataView, src: DataView) { 53 | let v0 = src.getUint32(0, false); 54 | let v1 = src.getUint32(4, false); 55 | 56 | let sum = 0; 57 | for (let i = 0; i < this.rounds / 2; i++) { 58 | sum = sum + TeaCipher.delta; 59 | v0 += ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1); 60 | v1 += ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3); 61 | } 62 | 63 | dst.setUint32(0, v0, false); 64 | dst.setUint32(4, v1, false); 65 | } 66 | 67 | decrypt(dst: DataView, src: DataView) { 68 | let v0 = src.getUint32(0, false); 69 | let v1 = src.getUint32(4, false); 70 | 71 | let sum = (TeaCipher.delta * this.rounds) / 2; 72 | for (let i = 0; i < this.rounds / 2; i++) { 73 | v1 -= ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3); 74 | v0 -= ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1); 75 | sum -= TeaCipher.delta; 76 | } 77 | dst.setUint32(0, v0, false); 78 | dst.setUint32(4, v1, false); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/decrypt/kgm.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioMimeType, 3 | BytesHasPrefix, 4 | GetArrayBuffer, 5 | GetCoverFromFile, 6 | GetMetaFromFile, 7 | SniffAudioExt, 8 | } from '@/decrypt/utils'; 9 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 10 | import { DecryptResult } from '@/decrypt/entity'; 11 | import { DecryptKgmWasm } from '@/decrypt/kgm_wasm'; 12 | import { decryptKgmByteAtOffsetV2, decryptVprByteAtOffset } from '@jixun/kugou-crypto/dist/utils/decryptionHelper'; 13 | 14 | //prettier-ignore 15 | const VprHeader = [ 16 | 0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, 17 | 0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31 18 | ] 19 | //prettier-ignore 20 | const KgmHeader = [ 21 | 0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, 22 | 0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14 23 | ] 24 | 25 | export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise { 26 | const oriData = await GetArrayBuffer(file); 27 | if (raw_ext === 'vpr') { 28 | if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!'); 29 | } else { 30 | if (!BytesHasPrefix(new Uint8Array(oriData), KgmHeader)) throw Error('Not a valid kgm(a) file!'); 31 | } 32 | let musicDecoded: Uint8Array | undefined; 33 | if (globalThis.WebAssembly) { 34 | console.log('kgm: using wasm decoder'); 35 | 36 | const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext); 37 | if (kgmDecrypted.success) { 38 | musicDecoded = kgmDecrypted.data; 39 | console.log('kgm wasm decoder suceeded'); 40 | } else { 41 | console.warn('KgmWasm failed with error %s', kgmDecrypted.error || '(unknown error)'); 42 | } 43 | } 44 | 45 | if (!musicDecoded) { 46 | musicDecoded = new Uint8Array(oriData); 47 | let bHeaderLen = new DataView(musicDecoded.slice(0x10, 0x14).buffer); 48 | let headerLen = bHeaderLen.getUint32(0, true); 49 | 50 | let key1 = Array.from(musicDecoded.slice(0x1c, 0x2c)); 51 | key1.push(0); 52 | 53 | musicDecoded = musicDecoded.slice(headerLen); 54 | let dataLen = musicDecoded.length; 55 | 56 | const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2; 57 | for (let i = 0; i < dataLen; i++) { 58 | musicDecoded[i] = decryptByte(musicDecoded[i], key1, i); 59 | } 60 | } 61 | 62 | const ext = SniffAudioExt(musicDecoded); 63 | const mime = AudioMimeType[ext]; 64 | let musicBlob = new Blob([musicDecoded], { type: mime }); 65 | const musicMeta = await metaParseBlob(musicBlob); 66 | const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || "")); 67 | return { 68 | album: musicMeta.common.album, 69 | picture: GetCoverFromFile(musicMeta), 70 | file: URL.createObjectURL(musicBlob), 71 | blob: musicBlob, 72 | ext, 73 | mime, 74 | title, 75 | artist, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /src/component/ConfigDialog.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 57 | 58 | 114 | -------------------------------------------------------------------------------- /src/utils/api.ts: -------------------------------------------------------------------------------- 1 | export const IXAREA_API_ENDPOINT = 'https://um-api.ixarea.com'; 2 | 3 | export interface UpdateInfo { 4 | Found: boolean; 5 | HttpsFound: boolean; 6 | Version: string; 7 | URL: string; 8 | Detail: string; 9 | } 10 | 11 | export async function checkUpdate(version: string): Promise { 12 | const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', { 13 | method: 'POST', 14 | headers: { 'Content-Type': 'application/json' }, 15 | body: JSON.stringify({ Version: version }), 16 | }); 17 | return await resp.json(); 18 | } 19 | 20 | export interface CoverInfo { 21 | Id: string; 22 | Type: number; 23 | } 24 | 25 | export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise { 26 | const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover'; 27 | const params = new URLSearchParams([ 28 | ['Title', title], 29 | ['Artist', artist ?? ''], 30 | ['Album', album ?? ''], 31 | ]); 32 | const resp = await fetch(`${endpoint}?${params.toString()}`); 33 | return await resp.json(); 34 | } 35 | 36 | export interface TrackInfo { 37 | id: number; 38 | type: number; 39 | mid: string; 40 | name: string; 41 | title: string; 42 | subtitle: string; 43 | singer: { 44 | id: number; 45 | mid: string; 46 | name: string; 47 | title: string; 48 | type: number; 49 | uin: number; 50 | }[]; 51 | album: { 52 | id: number; 53 | mid: string; 54 | name: string; 55 | title: string; 56 | subtitle: string; 57 | time_public: string; 58 | pmid: string; 59 | }; 60 | interval: number; 61 | index_cd: number; 62 | index_album: number; 63 | } 64 | 65 | export interface SongItemInfo { 66 | title: string; 67 | content: { 68 | value: string; 69 | }[]; 70 | } 71 | 72 | export interface SongInfoResponse { 73 | info: { 74 | company: SongItemInfo; 75 | genre: SongItemInfo; 76 | intro: SongItemInfo; 77 | lan: SongItemInfo; 78 | pub_time: SongItemInfo; 79 | }; 80 | extras: { 81 | name: string; 82 | transname: string; 83 | subtitle: string; 84 | from: string; 85 | wikiurl: string; 86 | }; 87 | track_info: TrackInfo; 88 | } 89 | 90 | export interface RawQMBatchResponse { 91 | code: number; 92 | ts: number; 93 | start_ts: number; 94 | traceid: string; 95 | req_1: { 96 | code: number; 97 | data: T; 98 | }; 99 | } 100 | 101 | export async function querySongInfoById(id: string | number): Promise { 102 | const url = `${IXAREA_API_ENDPOINT}/meta/qq-music-raw/${id}`; 103 | const result: RawQMBatchResponse = await fetch(url).then((r) => r.json()); 104 | if (result.code === 0 && result.req_1.code === 0) { 105 | return result.req_1.data; 106 | } 107 | 108 | throw new Error('请求信息失败'); 109 | } 110 | 111 | export function getQMImageURLFromPMID(pmid: string, type = 1): string { 112 | return `${IXAREA_API_ENDPOINT}/music/qq-cover/${type}/${pmid}`; 113 | } 114 | -------------------------------------------------------------------------------- /src/component/FileSelector.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 92 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 91 | 92 | 95 | -------------------------------------------------------------------------------- /src/decrypt/qmc_cipher.test.ts: -------------------------------------------------------------------------------- 1 | import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher } from '@/decrypt/qmc_cipher'; 2 | import fs from 'fs'; 3 | 4 | test('static cipher [0x7ff8,0x8000) ', () => { 5 | //prettier-ignore 6 | const expected = new Uint8Array([ 7 | 0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A, 8 | 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8, 9 | ]) 10 | 11 | const c = new QmcStaticCipher(); 12 | const buf = new Uint8Array(16); 13 | c.decrypt(buf, 0x7ff8); 14 | 15 | expect(buf).toStrictEqual(expected); 16 | }); 17 | 18 | test('static cipher [0,0x10) ', () => { 19 | //prettier-ignore 20 | const expected = new Uint8Array([ 21 | 0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 22 | 0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00, 23 | ]) 24 | 25 | const c = new QmcStaticCipher(); 26 | const buf = new Uint8Array(16); 27 | c.decrypt(buf, 0); 28 | 29 | expect(buf).toStrictEqual(expected); 30 | }); 31 | 32 | test('map cipher: get mask', () => { 33 | //prettier-ignore 34 | const expected = new Uint8Array([ 35 | 0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB, 36 | 0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79, 37 | ]) 38 | const key = new Uint8Array(256); 39 | for (let i = 0; i < 256; i++) key[i] = i; 40 | const buf = new Uint8Array(16); 41 | 42 | const c = new QmcMapCipher(key); 43 | c.decrypt(buf, 0); 44 | expect(buf).toStrictEqual(expected); 45 | }); 46 | 47 | function loadTestDataCipher(name: string): { 48 | key: Uint8Array; 49 | cipherText: Uint8Array; 50 | clearText: Uint8Array; 51 | } { 52 | return { 53 | key: fs.readFileSync(`testdata/${name}_key.bin`), 54 | cipherText: fs.readFileSync(`testdata/${name}_raw.bin`), 55 | clearText: fs.readFileSync(`testdata/${name}_target.bin`), 56 | }; 57 | } 58 | 59 | test('map cipher: real file', async () => { 60 | const cases = ['mflac_map', 'mgg_map']; 61 | for (const name of cases) { 62 | const { key, clearText, cipherText } = loadTestDataCipher(name); 63 | const c = new QmcMapCipher(key); 64 | 65 | c.decrypt(cipherText, 0); 66 | 67 | expect(cipherText).toStrictEqual(clearText); 68 | } 69 | }); 70 | 71 | test('rc4 cipher: real file', async () => { 72 | const cases = ['mflac0_rc4', 'mflac_rc4']; 73 | for (const name of cases) { 74 | const { key, clearText, cipherText } = loadTestDataCipher(name); 75 | const c = new QmcRC4Cipher(key); 76 | 77 | c.decrypt(cipherText, 0); 78 | 79 | expect(cipherText).toStrictEqual(clearText); 80 | } 81 | }); 82 | 83 | test('rc4 cipher: first segment', async () => { 84 | const cases = ['mflac0_rc4', 'mflac_rc4']; 85 | for (const name of cases) { 86 | const { key, clearText, cipherText } = loadTestDataCipher(name); 87 | const c = new QmcRC4Cipher(key); 88 | 89 | const buf = cipherText.slice(0, 128); 90 | c.decrypt(buf, 0); 91 | expect(buf).toStrictEqual(clearText.slice(0, 128)); 92 | } 93 | }); 94 | 95 | test('rc4 cipher: align block (128~5120)', async () => { 96 | const cases = ['mflac0_rc4', 'mflac_rc4']; 97 | for (const name of cases) { 98 | const { key, clearText, cipherText } = loadTestDataCipher(name); 99 | const c = new QmcRC4Cipher(key); 100 | 101 | const buf = cipherText.slice(128, 5120); 102 | c.decrypt(buf, 128); 103 | expect(buf).toStrictEqual(clearText.slice(128, 5120)); 104 | } 105 | }); 106 | 107 | test('rc4 cipher: simple block (5120~10240)', async () => { 108 | const cases = ['mflac0_rc4', 'mflac_rc4']; 109 | for (const name of cases) { 110 | const { key, clearText, cipherText } = loadTestDataCipher(name); 111 | const c = new QmcRC4Cipher(key); 112 | 113 | const buf = cipherText.slice(5120, 10240); 114 | c.decrypt(buf, 5120); 115 | expect(buf).toStrictEqual(clearText.slice(5120, 10240)); 116 | } 117 | }); 118 | -------------------------------------------------------------------------------- /src/decrypt/qmc_key.ts: -------------------------------------------------------------------------------- 1 | import { TeaCipher } from '@/utils/tea'; 2 | 3 | const SALT_LEN = 2; 4 | const ZERO_LEN = 7; 5 | 6 | export function QmcDeriveKey(raw: Uint8Array): Uint8Array { 7 | const textDec = new TextDecoder(); 8 | let rawDec = Buffer.from(textDec.decode(raw), 'base64'); 9 | let n = rawDec.length; 10 | if (n < 16) { 11 | throw Error('key length is too short'); 12 | } 13 | 14 | rawDec = decryptV2Key(rawDec); 15 | 16 | const simpleKey = simpleMakeKey(106, 8); 17 | let teaKey = new Uint8Array(16); 18 | for (let i = 0; i < 8; i++) { 19 | teaKey[i << 1] = simpleKey[i]; 20 | teaKey[(i << 1) + 1] = rawDec[i]; 21 | } 22 | const sub = decryptTencentTea(rawDec.subarray(8), teaKey); 23 | rawDec.set(sub, 8); 24 | return rawDec.subarray(0, 8 + sub.length); 25 | } 26 | 27 | // simpleMakeKey exported only for unit test 28 | export function simpleMakeKey(salt: number, length: number): number[] { 29 | const keyBuf: number[] = []; 30 | for (let i = 0; i < length; i++) { 31 | const tmp = Math.tan(salt + i * 0.1); 32 | keyBuf[i] = 0xff & (Math.abs(tmp) * 100.0); 33 | } 34 | return keyBuf; 35 | } 36 | 37 | const mixKey1: Uint8Array = new Uint8Array([ 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28 ]) 38 | const mixKey2: Uint8Array = new Uint8Array([ 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54 ]) 39 | 40 | function decryptV2Key(key: Buffer): Buffer 41 | { 42 | const textEnc = new TextDecoder(); 43 | if (key.length < 18 || textEnc.decode(key.slice(0, 18)) !== 'QQMusic EncV2,Key:') { 44 | return key; 45 | } 46 | 47 | let out = decryptTencentTea(key.slice(18), mixKey1); 48 | out = decryptTencentTea(out, mixKey2); 49 | const textDec = new TextDecoder(); 50 | const keyDec = Buffer.from(textDec.decode(out), 'base64'); 51 | let n = keyDec.length; 52 | if (n < 16) { 53 | throw Error('EncV2 key decode failed'); 54 | } 55 | 56 | return keyDec; 57 | } 58 | 59 | function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array { 60 | if (inBuf.length % 8 != 0) { 61 | throw Error('inBuf size not a multiple of the block size'); 62 | } 63 | if (inBuf.length < 16) { 64 | throw Error('inBuf size too small'); 65 | } 66 | 67 | const blk = new TeaCipher(key, 32); 68 | 69 | const tmpBuf = new Uint8Array(8); 70 | const tmpView = new DataView(tmpBuf.buffer); 71 | 72 | blk.decrypt(tmpView, new DataView(inBuf.buffer, inBuf.byteOffset, 8)); 73 | 74 | const nPadLen = tmpBuf[0] & 0x7; //只要最低三位 75 | /*密文格式:PadLen(1byte)+Padding(var,0-7byte)+Salt(2byte)+Body(var byte)+Zero(7byte)*/ 76 | const outLen = inBuf.length - 1 /*PadLen*/ - nPadLen - SALT_LEN - ZERO_LEN; 77 | const outBuf = new Uint8Array(outLen); 78 | 79 | let ivPrev = new Uint8Array(8); 80 | let ivCur = inBuf.slice(0, 8); // init iv 81 | let inBufPos = 8; 82 | 83 | // 跳过 Padding Len 和 Padding 84 | let tmpIdx = 1 + nPadLen; 85 | 86 | // CBC IV 处理 87 | const cryptBlock = () => { 88 | ivPrev = ivCur; 89 | ivCur = inBuf.slice(inBufPos, inBufPos + 8); 90 | for (let j = 0; j < 8; j++) { 91 | tmpBuf[j] ^= ivCur[j]; 92 | } 93 | blk.decrypt(tmpView, tmpView); 94 | inBufPos += 8; 95 | tmpIdx = 0; 96 | }; 97 | 98 | // 跳过 Salt 99 | for (let i = 1; i <= SALT_LEN; ) { 100 | if (tmpIdx < 8) { 101 | tmpIdx++; 102 | i++; 103 | } else { 104 | cryptBlock(); 105 | } 106 | } 107 | 108 | // 还原明文 109 | let outBufPos = 0; 110 | while (outBufPos < outLen) { 111 | if (tmpIdx < 8) { 112 | outBuf[outBufPos] = tmpBuf[tmpIdx] ^ ivPrev[tmpIdx]; 113 | outBufPos++; 114 | tmpIdx++; 115 | } else { 116 | cryptBlock(); 117 | } 118 | } 119 | 120 | // 校验Zero 121 | for (let i = 1; i <= ZERO_LEN; i++) { 122 | if (tmpBuf[tmpIdx] != ivPrev[tmpIdx]) { 123 | throw Error('zero check failed'); 124 | } 125 | } 126 | return outBuf; 127 | } 128 | -------------------------------------------------------------------------------- /src/decrypt/index.ts: -------------------------------------------------------------------------------- 1 | import { Decrypt as Mg3dDecrypt } from '@/decrypt/mg3d'; 2 | import { Decrypt as NcmDecrypt } from '@/decrypt/ncm'; 3 | import { Decrypt as NcmCacheDecrypt } from '@/decrypt/ncmcache'; 4 | import { Decrypt as XmDecrypt } from '@/decrypt/xm'; 5 | import { Decrypt as QmcDecrypt } from '@/decrypt/qmc'; 6 | import { Decrypt as QmcCacheDecrypt } from '@/decrypt/qmccache'; 7 | import { Decrypt as KgmDecrypt } from '@/decrypt/kgm'; 8 | import { Decrypt as KwmDecrypt } from '@/decrypt/kwm'; 9 | import { Decrypt as RawDecrypt } from '@/decrypt/raw'; 10 | import { Decrypt as TmDecrypt } from '@/decrypt/tm'; 11 | import { Decrypt as JooxDecrypt } from '@/decrypt/joox'; 12 | import { Decrypt as XimalayaDecrypt } from './ximalaya'; 13 | import { DecryptResult, FileInfo } from '@/decrypt/entity'; 14 | import { SplitFilename } from '@/decrypt/utils'; 15 | import { storage } from '@/utils/storage'; 16 | import InMemoryStorage from '@/utils/storage/InMemoryStorage'; 17 | 18 | export async function Decrypt(file: FileInfo, config: Record): Promise { 19 | // Worker thread will fallback to in-memory storage. 20 | if (storage instanceof InMemoryStorage) { 21 | await storage.setAll(config); 22 | } 23 | 24 | const raw = SplitFilename(file.name); 25 | let rt_data: DecryptResult; 26 | switch (raw.ext) { 27 | case 'mg3d': // Migu Wav 28 | rt_data = await Mg3dDecrypt(file.raw, raw.name); 29 | break; 30 | case 'ncm': // Netease Mp3/Flac 31 | rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext); 32 | break; 33 | case 'uc': // Netease Cache 34 | rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext); 35 | break; 36 | case 'kwm': // Kuwo Mp3/Flac 37 | rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext); 38 | break; 39 | case 'xm': // Xiami Wav/M4a/Mp3/Flac 40 | case 'wav': // Xiami/Raw Wav 41 | case 'mp3': // Xiami/Raw Mp3 42 | case 'flac': // Xiami/Raw Flac 43 | case 'm4a': // Xiami/Raw M4a 44 | rt_data = await XmDecrypt(file.raw, raw.name, raw.ext); 45 | break; 46 | case 'ogg': // Raw Ogg 47 | rt_data = await RawDecrypt(file.raw, raw.name, raw.ext); 48 | break; 49 | case 'tm0': // QQ Music IOS Mp3 50 | case 'tm3': // QQ Music IOS Mp3 51 | rt_data = await RawDecrypt(file.raw, raw.name, 'mp3'); 52 | break; 53 | case 'qmc0': //QQ Music Android Mp3 54 | case 'qmc3': //QQ Music Android Mp3 55 | case 'qmc2': //QQ Music Android Ogg 56 | case 'qmc4': //QQ Music Android Ogg 57 | case 'qmc6': //QQ Music Android Ogg 58 | case 'qmc8': //QQ Music Android Ogg 59 | case 'qmcflac': //QQ Music Android Flac 60 | case 'qmcogg': //QQ Music Android Ogg 61 | case 'tkm': //QQ Music Accompaniment M4a 62 | // Moo Music 63 | case 'bkcmp3': 64 | case 'bkcm4a': 65 | case 'bkcflac': 66 | case 'bkcwav': 67 | case 'bkcape': 68 | case 'bkcogg': 69 | case 'bkcwma': 70 | // QQ Music v2 71 | case 'mggl': //QQ Music Mac 72 | case 'mflac': //QQ Music New Flac 73 | case 'mflac0': //QQ Music New Flac 74 | case 'mflach': //QQ Music New Flac 75 | case 'mgg': //QQ Music New Ogg 76 | case 'mgg1': //QQ Music New Ogg 77 | case 'mgg0': 78 | case 'mmp4': // QMC MP4 Container w/ E-AC-3 JOC 79 | case '666c6163': //QQ Music Weiyun Flac 80 | case '6d7033': //QQ Music Weiyun Mp3 81 | case '6f6767': //QQ Music Weiyun Ogg 82 | case '6d3461': //QQ Music Weiyun M4a 83 | case '776176': //QQ Music Weiyun Wav 84 | rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext); 85 | break; 86 | case 'tm2': // QQ Music IOS M4a 87 | case 'tm6': // QQ Music IOS M4a 88 | rt_data = await TmDecrypt(file.raw, raw.name); 89 | break; 90 | case 'cache': //QQ Music Cache 91 | rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext); 92 | break; 93 | case 'vpr': 94 | case 'kgm': 95 | case 'kgma': 96 | rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext); 97 | break; 98 | case 'ofl_en': 99 | rt_data = await JooxDecrypt(file.raw, raw.name, raw.ext); 100 | break; 101 | case 'x2m': 102 | case 'x3m': 103 | rt_data = await XimalayaDecrypt(file.raw, raw.name, raw.ext); 104 | break; 105 | case 'mflach': //QQ Music New Flac 106 | throw '网页版无法解锁,请使用CLI版本' 107 | default: 108 | throw '不支持此文件格式'; 109 | } 110 | 111 | if (!rt_data.rawExt) rt_data.rawExt = raw.ext; 112 | if (!rt_data.rawFilename) rt_data.rawFilename = raw.name; 113 | console.log(rt_data); 114 | return rt_data; 115 | } 116 | -------------------------------------------------------------------------------- /src/utils/qm_meta.ts: -------------------------------------------------------------------------------- 1 | import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser'; 2 | import iconv from 'iconv-lite'; 3 | 4 | import { 5 | GetCoverFromFile, 6 | GetImageFromURL, 7 | GetMetaFromFile, 8 | WriteMetaToFlac, 9 | WriteMetaToMp3, 10 | AudioMimeType, 11 | split_regex, 12 | } from '@/decrypt/utils'; 13 | import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api'; 14 | 15 | interface MetaResult { 16 | title: string; 17 | artist: string; 18 | album: string; 19 | imgUrl: string; 20 | blob: Blob; 21 | } 22 | 23 | const fromGBK = (text?: string) => iconv.decode(new Buffer(text || ''), 'gbk'); 24 | 25 | /** 26 | * 27 | * @param musicBlob 音乐文件(解密后) 28 | * @param name 文件名 29 | * @param ext 原始后缀名 30 | * @param id 曲目 ID(number类型或纯数字组成的字符串) 31 | * @returns Promise 32 | */ 33 | export async function extractQQMusicMeta( 34 | musicBlob: Blob, 35 | name: string, 36 | ext: string, 37 | id?: number | string, 38 | ): Promise { 39 | const musicMeta = await metaParseBlob(musicBlob); 40 | for (let metaIdx in musicMeta.native) { 41 | if (!musicMeta.native.hasOwnProperty(metaIdx)) continue; 42 | if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) { 43 | console.warn('try using gbk encoding to decode meta'); 44 | musicMeta.common.artist = ''; 45 | if (!musicMeta.common.artists) { 46 | musicMeta.common.artist = fromGBK(musicMeta.common.artist); 47 | } 48 | else { 49 | musicMeta.common.artist = musicMeta.common.artists.map(fromGBK).join(); 50 | } 51 | musicMeta.common.title = fromGBK(musicMeta.common.title); 52 | musicMeta.common.album = fromGBK(musicMeta.common.album); 53 | } 54 | } 55 | 56 | if (id && id !== '0') { 57 | try { 58 | return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob); 59 | } catch (e) { 60 | console.warn('在线获取曲目信息失败,回退到本地 meta 提取', e); 61 | } 62 | } 63 | 64 | const info = GetMetaFromFile(name, musicMeta.common.title, musicMeta.common.artist); 65 | info.artist = info.artist || ''; 66 | 67 | let imageURL = GetCoverFromFile(musicMeta); 68 | if (!imageURL) { 69 | imageURL = await getCoverImage(info.title, info.artist, musicMeta.common.album); 70 | } 71 | 72 | return { 73 | title: info.title, 74 | artist: info.artist, 75 | album: musicMeta.common.album || '', 76 | imgUrl: imageURL, 77 | blob: await writeMetaToAudioFile({ 78 | title: info.title, 79 | artists: info.artist.split(split_regex), 80 | ext, 81 | imageURL, 82 | musicMeta, 83 | blob: musicBlob, 84 | }), 85 | }; 86 | } 87 | 88 | async function fetchMetadataFromSongId( 89 | id: number | string, 90 | ext: string, 91 | musicMeta: IAudioMetadata, 92 | blob: Blob, 93 | ): Promise { 94 | const info = await querySongInfoById(id); 95 | const imageURL = getQMImageURLFromPMID(info.track_info.album.pmid); 96 | const artists = info.track_info.singer.map((singer) => singer.name); 97 | 98 | return { 99 | title: info.track_info.title, 100 | artist: artists.join(','), 101 | album: info.track_info.album.name, 102 | imgUrl: imageURL, 103 | 104 | blob: await writeMetaToAudioFile({ 105 | title: info.track_info.title, 106 | artists, 107 | ext, 108 | imageURL, 109 | musicMeta, 110 | blob, 111 | }), 112 | }; 113 | } 114 | 115 | async function getCoverImage(title: string, artist?: string, album?: string): Promise { 116 | try { 117 | const data = await queryAlbumCover(title, artist, album); 118 | return getQMImageURLFromPMID(data.Id, data.Type); 119 | } catch (e) { 120 | console.warn(e); 121 | } 122 | return ''; 123 | } 124 | 125 | interface NewAudioMeta { 126 | title: string; 127 | artists: string[]; 128 | ext: string; 129 | 130 | musicMeta: IAudioMetadata; 131 | 132 | blob: Blob; 133 | imageURL: string; 134 | } 135 | 136 | async function writeMetaToAudioFile(info: NewAudioMeta): Promise { 137 | try { 138 | const imageInfo = await GetImageFromURL(info.imageURL); 139 | if (!imageInfo) { 140 | console.warn('获取图像失败'); 141 | } 142 | const newMeta = { picture: imageInfo?.buffer, title: info.title, artists: info.artists }; 143 | const buffer = Buffer.from(await info.blob.arrayBuffer()); 144 | const mime = AudioMimeType[info.ext] || AudioMimeType.mp3; 145 | if (info.ext === 'mp3') { 146 | return new Blob([WriteMetaToMp3(buffer, newMeta, info.musicMeta)], { type: mime }); 147 | } else if (info.ext === 'flac') { 148 | return new Blob([WriteMetaToFlac(buffer, newMeta, info.musicMeta)], { type: mime }); 149 | } else { 150 | console.info('writing metadata for ' + info.ext + ' is not being supported for now'); 151 | } 152 | } catch (e) { 153 | console.warn('Error while appending cover image to file ' + e); 154 | } 155 | return info.blob; 156 | } 157 | -------------------------------------------------------------------------------- /src/component/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 93 | 94 | 179 | -------------------------------------------------------------------------------- /src/scss/_dark-mode.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * 样式 - 暗黑模式 3 | */ 4 | 5 | @media (prefers-color-scheme: dark) { 6 | #app{ 7 | color: $dark-text-info; 8 | } 9 | body{ 10 | background-color: $dark-bg; 11 | } 12 | 13 | // FORM 14 | .el-radio{ 15 | &__label{ 16 | color: $dark-text-main; 17 | } 18 | &__input{ 19 | color: $dark-text-info; 20 | .el-radio__inner{ 21 | border-color: $dark-border; 22 | background-color: $dark-btn-bg; 23 | } 24 | } 25 | 26 | &.is-checked{ 27 | .el-radio__inner{ 28 | background-color: $blue; 29 | } 30 | .el-radio__label{ 31 | font-weight: bold; 32 | } 33 | } 34 | } 35 | 36 | .el-checkbox.is-bordered{ 37 | border-color: $dark-border; 38 | color: $dark-text-main; 39 | background-color: $dark-btn-bg; 40 | .el-checkbox__inner{ 41 | background-color: $dark-btn-bg-highlight; 42 | border-color: $dark-border-highlight; 43 | } 44 | &:hover{ 45 | border-color: $dark-border-highlight; 46 | .el-checkbox__inner{ 47 | background-color: $dark-btn-bg-highlight; 48 | border-color: $dark-border-highlight; 49 | } 50 | .el-checkbox__label{ 51 | color: $dark-text-info; 52 | } 53 | } 54 | &.is-checked{ 55 | background-color: $blue; 56 | .el-checkbox__inner{ 57 | border-color: white; 58 | } 59 | .el-checkbox__label{ 60 | color: white; 61 | font-weight: bold; 62 | } 63 | &:hover{ 64 | border-color: $blue; 65 | .el-checkbox__inner{ 66 | background-color: white; 67 | } 68 | } 69 | } 70 | } 71 | 72 | 73 | 74 | // BUTTON 75 | .el-button{ 76 | background-color: $dark-btn-bg; 77 | border-color: $dark-border; 78 | color: $dark-text-main; 79 | 80 | &:active{ 81 | transform: translateY(2px); 82 | } 83 | 84 | &--default{ 85 | &.is-plain { 86 | background-color: $dark-btn-bg; 87 | &:hover { 88 | background-color: $blue; 89 | border-color: $blue; 90 | color: white; 91 | } 92 | } 93 | &.is-circle { 94 | background-color: $dark-blue; 95 | border-color: $dark-blue; 96 | &:hover { 97 | background-color: $blue; 98 | border-color: $blue; 99 | color: white; 100 | } 101 | } 102 | } 103 | 104 | &--success{ 105 | &.is-plain { 106 | background-color: $dark-btn-bg; 107 | &:hover { 108 | background-color: $green; 109 | border-color: $green; 110 | color: white; 111 | } 112 | } 113 | &.is-circle { 114 | background-color: $dark-green; 115 | border-color: $dark-green; 116 | &:hover { 117 | background-color: $green; 118 | border-color: $green; 119 | color: white; 120 | } 121 | } 122 | } 123 | &--danger{ 124 | &.is-plain{ 125 | border-color: $dark-border; 126 | background-color: $dark-btn-bg; 127 | &:hover{ 128 | background-color: $red; 129 | border-color: $red; 130 | } 131 | } 132 | &.is-circle { 133 | background-color: $dark-red; 134 | border-color: $dark-red; 135 | &:hover { 136 | background-color: $red; 137 | border-color: $red; 138 | color: white; 139 | } 140 | } 141 | } 142 | } 143 | 144 | // 文件拖放区 145 | .el-upload__tip{ 146 | color: $dark-text-info; 147 | } 148 | .el-upload-dragger{ 149 | background-color: $dark-uploader-bg; 150 | border-color: $dark-border; 151 | .el-upload__text{ 152 | color: $dark-text-info; 153 | } 154 | &:hover{ 155 | background: $dark-uploader-bg-highlight; 156 | border-color: $dark-border-highlight; 157 | } 158 | } 159 | 160 | // TABLE 161 | .el-table{ 162 | background-color: $dark-bg-td; 163 | &:before{ // 去除表格末尾的横线 164 | content: none; 165 | } 166 | &__header{ 167 | th{ 168 | border-bottom-color: $dark-border !important; 169 | } 170 | } 171 | th.el-table__cell{ 172 | background-color: $dark-bg-th; 173 | color: $dark-text-info; 174 | } 175 | td{ 176 | border-bottom-color: $dark-border !important; 177 | } 178 | tr{ 179 | background-color: $dark-bg-td; 180 | color: $dark-text-main; 181 | &:hover{ 182 | td{ 183 | background-color: $dark-bg-th !important; 184 | } 185 | } 186 | } 187 | } 188 | 189 | // LINKS 190 | a{ 191 | text-decoration: none; 192 | color: darken($dark-color-link, 15%); 193 | &:hover{ 194 | color: $dark-color-link; 195 | } 196 | } 197 | 198 | // ALERT 199 | .el-notification{ 200 | background-color: $dark-btn-bg-highlight; 201 | border-color: $dark-border; 202 | &__title{ 203 | color: white; 204 | } 205 | &__content{ 206 | color: $dark-text-info; 207 | } 208 | } 209 | 210 | // DIALOG 211 | .el-dialog{ 212 | background-color: $dark-dialog-bg; 213 | .el-dialog__header{ 214 | .el-dialog__title{ 215 | color: $dark-text-main; 216 | } 217 | } 218 | .el-dialog__body{ 219 | color: $dark-text-main; 220 | .el-input{ 221 | .el-input__inner{ 222 | color: $dark-text-main; 223 | background-color: $dark-btn-bg; 224 | } 225 | .el-input__suffix{ 226 | .el-input__suffix-inner{ 227 | } 228 | } 229 | .el-input__count{ 230 | .el-input__count-inner{ 231 | background-color: transparent; 232 | } 233 | } 234 | } 235 | } 236 | .item-desc{ 237 | color: $dark-text-info; 238 | } 239 | } 240 | 241 | 242 | 243 | // 自定义样式 244 | // 首页弹窗提示信息的 更新信息 面板 245 | .update-info{ 246 | border: 1px solid $dark-btn-bg !important; 247 | .update-title{ 248 | color: $dark-text-main; 249 | background-color: $dark-btn-bg !important; 250 | } 251 | .update-content{ 252 | color: $dark-text-info; 253 | padding: 10px; 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /src/decrypt/qmc.ts: -------------------------------------------------------------------------------- 1 | import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher'; 2 | import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils'; 3 | 4 | import { DecryptResult } from '@/decrypt/entity'; 5 | import { QmcDeriveKey } from '@/decrypt/qmc_key'; 6 | import { DecryptQmcWasm } from '@/decrypt/qmc_wasm'; 7 | import { extractQQMusicMeta } from '@/utils/qm_meta'; 8 | 9 | interface Handler { 10 | ext: string; 11 | version: number; 12 | } 13 | 14 | export const HandlerMap: { [key: string]: Handler } = { 15 | mgg: { ext: 'ogg', version: 2 }, 16 | mgg0: { ext: 'ogg', version: 2 }, 17 | mggl: { ext: 'ogg', version: 2 }, 18 | mgg1: { ext: 'ogg', version: 2 }, 19 | mflac: { ext: 'flac', version: 2 }, 20 | mflac0: { ext: 'flac', version: 2 }, 21 | mmp4: { ext: 'mmp4', version: 2 }, 22 | 23 | // qmcflac / qmcogg: 24 | // 有可能是 v2 加密但混用同一个后缀名。 25 | qmcflac: { ext: 'flac', version: 2 }, 26 | qmcogg: { ext: 'ogg', version: 2 }, 27 | 28 | qmc0: { ext: 'mp3', version: 2 }, 29 | qmc2: { ext: 'ogg', version: 2 }, 30 | qmc3: { ext: 'mp3', version: 2 }, 31 | qmc4: { ext: 'ogg', version: 2 }, 32 | qmc6: { ext: 'ogg', version: 2 }, 33 | qmc8: { ext: 'ogg', version: 2 }, 34 | bkcmp3: { ext: 'mp3', version: 1 }, 35 | bkcm4a: { ext: 'm4a', version: 1 }, 36 | bkcflac: { ext: 'flac', version: 1 }, 37 | bkcwav: { ext: 'wav', version: 1 }, 38 | bkcape: { ext: 'ape', version: 1 }, 39 | bkcogg: { ext: 'ogg', version: 1 }, 40 | bkcwma: { ext: 'wma', version: 1 }, 41 | tkm: { ext: 'm4a', version: 1 }, 42 | '666c6163': { ext: 'flac', version: 1 }, 43 | '6d7033': { ext: 'mp3', version: 1 }, 44 | '6f6767': { ext: 'ogg', version: 1 }, 45 | '6d3461': { ext: 'm4a', version: 1 }, 46 | '776176': { ext: 'wav', version: 1 }, 47 | }; 48 | 49 | export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { 50 | if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`; 51 | const handler = HandlerMap[raw_ext]; 52 | let { version } = handler; 53 | 54 | const fileBuffer = await GetArrayBuffer(file); 55 | let musicDecoded: Uint8Array | undefined; 56 | let musicID: number | string | undefined; 57 | 58 | if (version === 2 && globalThis.WebAssembly) { 59 | console.log('qmc: using wasm decoder'); 60 | 61 | const v2Decrypted = await DecryptQmcWasm(fileBuffer, raw_ext); 62 | // 若 v2 检测失败,降级到 v1 再尝试一次 63 | if (v2Decrypted.success) { 64 | musicDecoded = v2Decrypted.data; 65 | musicID = v2Decrypted.songId; 66 | console.log('qmc wasm decoder suceeded'); 67 | } else { 68 | console.warn('QmcWasm failed with error %s', v2Decrypted.error || '(unknown error)'); 69 | } 70 | } 71 | 72 | if (!musicDecoded) { 73 | // may throw error 74 | console.log('qmc: using js decoder'); 75 | const d = new QmcDecoder(new Uint8Array(fileBuffer)); 76 | musicDecoded = d.decrypt(); 77 | musicID = d.songID; 78 | } 79 | 80 | const ext = SniffAudioExt(musicDecoded, handler.ext); 81 | const mime = AudioMimeType[ext]; 82 | 83 | const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( 84 | new Blob([musicDecoded], { type: mime }), 85 | raw_filename, 86 | ext, 87 | musicID, 88 | ); 89 | 90 | return { 91 | title: title, 92 | artist: artist, 93 | ext: ext, 94 | album: album, 95 | picture: imgUrl, 96 | file: URL.createObjectURL(blob), 97 | blob: blob, 98 | mime: mime, 99 | }; 100 | } 101 | 102 | export class QmcDecoder { 103 | private static readonly BYTE_COMMA = ','.charCodeAt(0); 104 | private readonly file: Uint8Array; 105 | private readonly size: number; 106 | private decoded: boolean = false; 107 | private audioSize?: number; 108 | private cipher?: QmcStreamCipher; 109 | 110 | public constructor(file: Uint8Array) { 111 | this.file = file; 112 | this.size = file.length; 113 | this.searchKey(); 114 | } 115 | 116 | private _songID?: number; 117 | 118 | public get songID() { 119 | return this._songID; 120 | } 121 | 122 | public decrypt(): Uint8Array { 123 | if (!this.cipher) { 124 | throw new Error('no cipher found'); 125 | } 126 | if (!this.audioSize || this.audioSize <= 0) { 127 | throw new Error('invalid audio size'); 128 | } 129 | const audioBuf = this.file.subarray(0, this.audioSize); 130 | 131 | if (!this.decoded) { 132 | this.cipher.decrypt(audioBuf, 0); 133 | this.decoded = true; 134 | } 135 | 136 | return audioBuf; 137 | } 138 | 139 | private searchKey() { 140 | const last4Byte = this.file.slice(-4); 141 | const textEnc = new TextDecoder(); 142 | if (textEnc.decode(last4Byte) === 'STag') { 143 | throw new Error('文件中没有写入密钥,无法解锁,请降级App并重试'); 144 | } else if (textEnc.decode(last4Byte) === 'QTag') { 145 | const sizeBuf = this.file.slice(-8, -4); 146 | const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset); 147 | const keySize = sizeView.getUint32(0, false); 148 | this.audioSize = this.size - keySize - 8; 149 | 150 | const rawKey = this.file.subarray(this.audioSize, this.size - 8); 151 | const keyEnd = rawKey.findIndex((v) => v == QmcDecoder.BYTE_COMMA); 152 | if (keyEnd < 0) { 153 | throw new Error('invalid key: search raw key failed'); 154 | } 155 | this.setCipher(rawKey.subarray(0, keyEnd)); 156 | 157 | const idBuf = rawKey.subarray(keyEnd + 1); 158 | const idEnd = idBuf.findIndex((v) => v == QmcDecoder.BYTE_COMMA); 159 | if (keyEnd < 0) { 160 | throw new Error('invalid key: search song id failed'); 161 | } 162 | this._songID = parseInt(textEnc.decode(idBuf.subarray(0, idEnd)), 10); 163 | } else { 164 | const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset); 165 | const keySize = sizeView.getUint32(0, true); 166 | if (keySize < 0x400) { 167 | this.audioSize = this.size - keySize - 4; 168 | const rawKey = this.file.subarray(this.audioSize, this.size - 4); 169 | this.setCipher(rawKey); 170 | } else { 171 | this.audioSize = this.size; 172 | this.cipher = new QmcStaticCipher(); 173 | } 174 | } 175 | } 176 | 177 | private setCipher(keyRaw: Uint8Array) { 178 | const keyDec = QmcDeriveKey(keyRaw); 179 | if (keyDec.length > 300) { 180 | this.cipher = new QmcRC4Cipher(keyDec); 181 | } else { 182 | this.cipher = new QmcMapCipher(keyDec); 183 | } 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/QmcWasm/qmc.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include "qmc_key.hpp" 6 | #include "qmc_cipher.hpp" 7 | 8 | class QmcDecode { 9 | private: 10 | std::vector blobData; 11 | 12 | std::vector rawKeyBuf; 13 | std::string cipherType = ""; 14 | 15 | size_t dataOffset = 0; 16 | size_t keySize = 0; 17 | int mediaVer = 0; 18 | 19 | std::string checkType(std::string fn) { 20 | if (fn.find(".qmc") < fn.size() || fn.find(".m") < fn.size()) 21 | { 22 | std::string buf_tag = ""; 23 | for (int i = 4; i > 0; --i) 24 | { 25 | buf_tag += *((char*)blobData.data() + blobData.size() - i); 26 | } 27 | if (buf_tag == "QTag") 28 | { 29 | keySize = ntohl(*(uint32_t*)(blobData.data() + blobData.size() - 8)); 30 | return "QTag"; 31 | } 32 | else if (buf_tag == "STag") 33 | { 34 | return "STag"; 35 | } 36 | else 37 | { 38 | keySize = (*(uint32_t*)(blobData.data() + blobData.size() - 4)); 39 | if (keySize < 0x400) 40 | { 41 | return "Map/RC4"; 42 | } 43 | else 44 | { 45 | keySize = 0; 46 | return "Static"; 47 | } 48 | } 49 | } 50 | else if (fn.find(".cache") < fn.size()) 51 | { 52 | return "cache"; 53 | } 54 | else if (fn.find(".tm") < fn.size()) 55 | { 56 | return "ios"; 57 | } 58 | else 59 | { 60 | return "invalid"; 61 | } 62 | } 63 | 64 | bool parseRawKeyQTag() { 65 | std::string ketStr = ""; 66 | std::string::size_type index = 0; 67 | ketStr.append((char*)rawKeyBuf.data(), rawKeyBuf.size()); 68 | index = ketStr.find(",", 0); 69 | if (index != std::string::npos) 70 | { 71 | rawKeyBuf.resize(index); 72 | } 73 | else 74 | { 75 | return false; 76 | } 77 | ketStr = ketStr.substr(index + 1); 78 | index = ketStr.find(",", 0); 79 | if (index != std::string::npos) 80 | { 81 | this->songId = ketStr.substr(0, index); 82 | } 83 | else 84 | { 85 | return false; 86 | } 87 | ketStr = ketStr.substr(index + 1); 88 | index = ketStr.find(",", 0); 89 | if (index == std::string::npos) 90 | { 91 | this->mediaVer = std::stoi(ketStr); 92 | } 93 | else 94 | { 95 | return false; 96 | } 97 | return true; 98 | } 99 | 100 | bool readRawKey(size_t tailSize) { 101 | // get raw key data length 102 | rawKeyBuf.resize(keySize); 103 | if (rawKeyBuf.size() != keySize) { 104 | return false; 105 | } 106 | for (size_t i = 0; i < keySize; i++) 107 | { 108 | rawKeyBuf[i] = blobData[i + blobData.size() - (tailSize + keySize)]; 109 | } 110 | return true; 111 | } 112 | 113 | void DecodeStatic(); 114 | 115 | void DecodeMapRC4(); 116 | 117 | void DecodeCache(); 118 | 119 | void DecodeTm(); 120 | 121 | public: 122 | bool SetBlob(uint8_t* blob, size_t blobSize) { 123 | blobData.resize(blobSize); 124 | if (blobData.size() != blobSize) { 125 | return false; 126 | } 127 | memcpy(blobData.data(), blob, blobSize); 128 | return true; 129 | } 130 | 131 | int PreDecode(std::string ext) { 132 | cipherType = checkType(ext); 133 | size_t tailSize = 0; 134 | if (cipherType == "invalid" || cipherType == "STag") { 135 | error = "file is invalid or not supported (Please downgrade your app)."; 136 | return -1; 137 | } 138 | if (cipherType == "QTag") { 139 | tailSize = 8; 140 | } 141 | else if (cipherType == "Map/RC4") { 142 | tailSize = 4; 143 | } 144 | if (keySize > 0) { 145 | if (!readRawKey(tailSize)) { 146 | error = "cannot read embedded key from file"; 147 | return -1; 148 | } 149 | if (tailSize == 8) { 150 | cipherType = "Map/RC4"; 151 | if (!parseRawKeyQTag()) { 152 | error = "cannot parse embedded key"; 153 | return -1; 154 | } 155 | } 156 | std::vector tmp; 157 | if (!QmcDecryptKey(rawKeyBuf, tmp)) { 158 | error = "cannot decrypt embedded key"; 159 | return -1; 160 | } 161 | rawKeyBuf = tmp; 162 | } 163 | return keySize + tailSize; 164 | } 165 | 166 | std::vector Decode(size_t offset); 167 | 168 | std::string songId = ""; 169 | std::string error = ""; 170 | }; 171 | 172 | void QmcDecode::DecodeStatic() 173 | { 174 | QmcStaticCipher sc; 175 | sc.proc(blobData, dataOffset); 176 | } 177 | 178 | void QmcDecode::DecodeMapRC4() { 179 | if (rawKeyBuf.size() > 300) 180 | { 181 | QmcRC4Cipher c(rawKeyBuf, 2); 182 | c.proc(blobData, dataOffset); 183 | } 184 | else 185 | { 186 | QmcMapCipher c(rawKeyBuf, 2); 187 | c.proc(blobData, dataOffset); 188 | } 189 | } 190 | 191 | void QmcDecode::DecodeCache() 192 | { 193 | for (size_t i = 0; i < blobData.size(); i++) { 194 | blobData[i] ^= 0xf4; 195 | blobData[i] = ((blobData[i] & 0b00111111) << 2) | (blobData[i] >> 6); // rol 2 196 | } 197 | } 198 | 199 | void QmcDecode::DecodeTm() 200 | { 201 | uint8_t const TM_HEADER[] = { 0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70 }; 202 | for (size_t cur = dataOffset, i = 0; cur < 8 && i < blobData.size(); ++cur, ++i) { 203 | blobData[i] = TM_HEADER[dataOffset]; 204 | } 205 | } 206 | 207 | std::vector QmcDecode::Decode(size_t offset) 208 | { 209 | dataOffset = offset; 210 | if (cipherType == "Map/RC4") 211 | { 212 | DecodeMapRC4(); 213 | } 214 | else if (cipherType == "Static") 215 | { 216 | DecodeStatic(); 217 | } 218 | else if (cipherType == "cache") 219 | { 220 | DecodeCache(); 221 | } 222 | else if (cipherType == "ios") 223 | { 224 | DecodeTm(); 225 | } 226 | else { 227 | error = "File is invalid or encryption type is not supported."; 228 | } 229 | return blobData; 230 | } 231 | -------------------------------------------------------------------------------- /src/QmcWasm/qmc_key.hpp: -------------------------------------------------------------------------------- 1 | #include"TencentTea.hpp" 2 | #include "base64.hpp" 3 | 4 | void simpleMakeKey(uint8_t salt, int length, std::vector &key_buf) { 5 | for (size_t i = 0; i < length; ++i) { 6 | double tmp = tan((float)salt + (double)i * 0.1); 7 | key_buf[i] = 0xFF & (uint8_t)(fabs(tmp) * 100.0); 8 | } 9 | } 10 | 11 | std::vector v2KeyPrefix = { 0x51, 0x51, 0x4D, 0x75, 0x73, 0x69, 0x63, 0x20, 0x45, 0x6E, 0x63, 0x56, 0x32, 0x2C, 0x4B, 0x65, 0x79, 0x3A }; 12 | 13 | bool decryptV2Key(std::vector key, std::vector& outVec) 14 | { 15 | if (v2KeyPrefix.size() > key.size()) 16 | { 17 | return true; 18 | } 19 | for (size_t i = 0; i < v2KeyPrefix.size(); i++) 20 | { 21 | if (key[i] != v2KeyPrefix[i]) 22 | { 23 | return true; 24 | } 25 | } 26 | 27 | std::vector mixKey1 = { 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28 }; 28 | std::vector mixKey2 = { 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54 }; 29 | 30 | std::vector out; 31 | std::vector tmpKey; 32 | tmpKey.resize(key.size() - 18); 33 | for (size_t i = 0; i < tmpKey.size(); i++) 34 | { 35 | tmpKey[i] = key[18 + i]; 36 | } 37 | if (!decryptTencentTea(tmpKey, mixKey1, out)) 38 | { 39 | outVec.resize(0); 40 | //EncV2 key decode failed. 41 | return false; 42 | } 43 | 44 | tmpKey.resize(out.size()); 45 | for (size_t i = 0; i < tmpKey.size(); i++) 46 | { 47 | tmpKey[i] = out[i]; 48 | } 49 | out.resize(0); 50 | if (!decryptTencentTea(tmpKey, mixKey2, out)) 51 | { 52 | outVec.resize(0); 53 | //EncV2 key decode failed. 54 | return false; 55 | } 56 | 57 | outVec.resize(base64::decoded_size(out.size())); 58 | auto n = base64::decode(outVec.data(), (const char*)(out.data()), out.size()).first; 59 | 60 | if (n < 16) 61 | { 62 | outVec.resize(0); 63 | //EncV2 key size is too small. 64 | return false; 65 | } 66 | outVec.resize(n); 67 | 68 | return true; 69 | } 70 | 71 | bool encryptV2Key(std::vector key, std::vector& outVec) 72 | { 73 | if (key.size() < 16) 74 | { 75 | outVec.resize(0); 76 | //EncV2 key size is too small. 77 | return false; 78 | } 79 | 80 | std::vector in; 81 | in.resize(base64::encoded_size(key.size())); 82 | auto n = base64::encode(in.data(), (const char*)(key.data()), key.size()); 83 | in.resize(n); 84 | 85 | std::vector mixKey1 = { 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28 }; 86 | std::vector mixKey2 = { 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54 }; 87 | 88 | std::vector tmpKey; 89 | if (!encryptTencentTea(in, mixKey2, tmpKey)) 90 | { 91 | outVec.resize(0); 92 | //EncV2 key decode failed. 93 | return false; 94 | } 95 | in.resize(tmpKey.size()); 96 | for (size_t i = 0; i < tmpKey.size(); i++) 97 | { 98 | in[i] = tmpKey[i]; 99 | } 100 | tmpKey.resize(0); 101 | 102 | if (!encryptTencentTea(in, mixKey1, tmpKey)) 103 | { 104 | outVec.resize(0); 105 | //EncV2 key decode failed. 106 | return false; 107 | } 108 | outVec.resize(tmpKey.size() + 18); 109 | for (size_t i = 0; i < tmpKey.size(); i++) 110 | { 111 | outVec[18 + i] = tmpKey[i]; 112 | } 113 | 114 | for (size_t i = 0; i < v2KeyPrefix.size(); i++) 115 | { 116 | outVec[i] = v2KeyPrefix[i]; 117 | } 118 | 119 | return true; 120 | } 121 | 122 | bool QmcDecryptKey(std::vector raw, std::vector &outVec) { 123 | std::vector rawDec; 124 | rawDec.resize(base64::decoded_size(raw.size())); 125 | auto n = base64::decode(rawDec.data(), (const char*)(raw.data()), raw.size()).first; 126 | if (n < 16) { 127 | return false; 128 | //key length is too short 129 | } 130 | rawDec.resize(n); 131 | 132 | std::vector tmpIn = rawDec; 133 | if (!decryptV2Key(tmpIn, rawDec)) 134 | { 135 | //decrypt EncV2 failed. 136 | return false; 137 | } 138 | 139 | std::vector simpleKey; 140 | simpleKey.resize(8); 141 | simpleMakeKey(106, 8, simpleKey); 142 | std::vector teaKey; 143 | teaKey.resize(16); 144 | for (size_t i = 0; i < 8; i++) { 145 | teaKey[i << 1] = simpleKey[i]; 146 | teaKey[(i << 1) + 1] = rawDec[i]; 147 | } 148 | std::vector out; 149 | std::vector tmpRaw; 150 | tmpRaw.resize(rawDec.size() - 8); 151 | for (size_t i = 0; i < tmpRaw.size(); i++) 152 | { 153 | tmpRaw[i] = rawDec[8 + i]; 154 | } 155 | if (decryptTencentTea(tmpRaw, teaKey, out)) 156 | { 157 | rawDec.resize(8 + out.size()); 158 | for (size_t i = 0; i < out.size(); i++) 159 | { 160 | rawDec[8 + i] = out[i]; 161 | } 162 | outVec = rawDec; 163 | return true; 164 | } 165 | else 166 | { 167 | return false; 168 | } 169 | } 170 | 171 | bool QmcEncryptKey(std::vector raw, std::vector& outVec, bool useEncV2 = true) { 172 | std::vector simpleKey; 173 | simpleKey.resize(8); 174 | simpleMakeKey(106, 8, simpleKey); 175 | std::vector teaKey; 176 | teaKey.resize(16); 177 | for (size_t i = 0; i < 8; i++) { 178 | teaKey[i << 1] = simpleKey[i]; 179 | teaKey[(i << 1) + 1] = raw[i]; 180 | } 181 | std::vector out; 182 | out.resize(raw.size() - 8); 183 | for (size_t i = 0; i < out.size(); i++) 184 | { 185 | out[i] = raw[8 + i]; 186 | } 187 | std::vector tmpRaw; 188 | if (encryptTencentTea(out, teaKey, tmpRaw)) 189 | { 190 | raw.resize(tmpRaw.size() + 8); 191 | for (size_t i = 0; i < tmpRaw.size(); i++) 192 | { 193 | raw[i + 8] = tmpRaw[i]; 194 | } 195 | 196 | if (useEncV2) 197 | { 198 | std::vector tmpIn = raw; 199 | if (!encryptV2Key(tmpIn, raw)) 200 | { 201 | //encrypt EncV2 failed. 202 | return false; 203 | } 204 | } 205 | 206 | std::vector rawEnc; 207 | rawEnc.resize(base64::encoded_size(raw.size())); 208 | auto n = base64::encode(rawEnc.data(), (const char*)(raw.data()), raw.size()); 209 | rawEnc.resize(n); 210 | outVec = rawEnc; 211 | return true; 212 | } 213 | else 214 | { 215 | return false; 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/KgmWasm/kgm.hpp: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | std::vector VprHeader = { 4 | 0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, 5 | 0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31 }; 6 | std::vector KgmHeader = { 7 | 0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, 8 | 0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14 }; 9 | std::vector VprMaskDiff = { 10 | 0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E, 11 | 0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11, 0x00 }; 12 | 13 | std::vector MaskV2; 14 | 15 | std::vector table1 = { 16 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 17 | 0x00, 0x01, 0x21, 0x01, 0x61, 0x01, 0x21, 0x01, 0xe1, 0x01, 0x21, 0x01, 0x61, 0x01, 0x21, 0x01, 18 | 0xd2, 0x23, 0x02, 0x02, 0x42, 0x42, 0x02, 0x02, 0xc2, 0xc2, 0x02, 0x02, 0x42, 0x42, 0x02, 0x02, 19 | 0xd3, 0xd3, 0x02, 0x03, 0x63, 0x43, 0x63, 0x03, 0xe3, 0xc3, 0xe3, 0x03, 0x63, 0x43, 0x63, 0x03, 20 | 0x94, 0xb4, 0x94, 0x65, 0x04, 0x04, 0x04, 0x04, 0x84, 0x84, 0x84, 0x84, 0x04, 0x04, 0x04, 0x04, 21 | 0x95, 0x95, 0x95, 0x95, 0x04, 0x05, 0x25, 0x05, 0xe5, 0x85, 0xa5, 0x85, 0xe5, 0x05, 0x25, 0x05, 22 | 0xd6, 0xb6, 0x96, 0xb6, 0xd6, 0x27, 0x06, 0x06, 0xc6, 0xc6, 0x86, 0x86, 0xc6, 0xc6, 0x06, 0x06, 23 | 0xd7, 0xd7, 0x97, 0x97, 0xd7, 0xd7, 0x06, 0x07, 0xe7, 0xc7, 0xe7, 0x87, 0xe7, 0xc7, 0xe7, 0x07, 24 | 0x18, 0x38, 0x18, 0x78, 0x18, 0x38, 0x18, 0xe9, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 25 | 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x19, 0x08, 0x09, 0x29, 0x09, 0x69, 0x09, 0x29, 0x09, 26 | 0xda, 0x3a, 0x1a, 0x3a, 0x5a, 0x3a, 0x1a, 0x3a, 0xda, 0x2b, 0x0a, 0x0a, 0x4a, 0x4a, 0x0a, 0x0a, 27 | 0xdb, 0xdb, 0x1b, 0x1b, 0x5b, 0x5b, 0x1b, 0x1b, 0xdb, 0xdb, 0x0a, 0x0b, 0x6b, 0x4b, 0x6b, 0x0b, 28 | 0x9c, 0xbc, 0x9c, 0x7c, 0x1c, 0x3c, 0x1c, 0x7c, 0x9c, 0xbc, 0x9c, 0x6d, 0x0c, 0x0c, 0x0c, 0x0c, 29 | 0x9d, 0x9d, 0x9d, 0x9d, 0x1d, 0x1d, 0x1d, 0x1d, 0x9d, 0x9d, 0x9d, 0x9d, 0x0c, 0x0d, 0x2d, 0x0d, 30 | 0xde, 0xbe, 0x9e, 0xbe, 0xde, 0x3e, 0x1e, 0x3e, 0xde, 0xbe, 0x9e, 0xbe, 0xde, 0x2f, 0x0e, 0x0e, 31 | 0xdf, 0xdf, 0x9f, 0x9f, 0xdf, 0xdf, 0x1f, 0x1f, 0xdf, 0xdf, 0x9f, 0x9f, 0xdf, 0xdf, 0x0e, 0x0f, 32 | 0x00, 0x20, 0x00, 0x60, 0x00, 0x20, 0x00, 0xe0, 0x00, 0x20, 0x00, 0x60, 0x00, 0x20, 0x00, 0xf1 33 | }; 34 | 35 | std::vector table2 = { 36 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 37 | 0x00, 0x01, 0x23, 0x01, 0x67, 0x01, 0x23, 0x01, 0xef, 0x01, 0x23, 0x01, 0x67, 0x01, 0x23, 0x01, 38 | 0xdf, 0x21, 0x02, 0x02, 0x46, 0x46, 0x02, 0x02, 0xce, 0xce, 0x02, 0x02, 0x46, 0x46, 0x02, 0x02, 39 | 0xde, 0xde, 0x02, 0x03, 0x65, 0x47, 0x65, 0x03, 0xed, 0xcf, 0xed, 0x03, 0x65, 0x47, 0x65, 0x03, 40 | 0x9d, 0xbf, 0x9d, 0x63, 0x04, 0x04, 0x04, 0x04, 0x8c, 0x8c, 0x8c, 0x8c, 0x04, 0x04, 0x04, 0x04, 41 | 0x9c, 0x9c, 0x9c, 0x9c, 0x04, 0x05, 0x27, 0x05, 0xeb, 0x8d, 0xaf, 0x8d, 0xeb, 0x05, 0x27, 0x05, 42 | 0xdb, 0xbd, 0x9f, 0xbd, 0xdb, 0x25, 0x06, 0x06, 0xca, 0xca, 0x8e, 0x8e, 0xca, 0xca, 0x06, 0x06, 43 | 0xda, 0xda, 0x9e, 0x9e, 0xda, 0xda, 0x06, 0x07, 0xe9, 0xcb, 0xe9, 0x8f, 0xe9, 0xcb, 0xe9, 0x07, 44 | 0x19, 0x3b, 0x19, 0x7f, 0x19, 0x3b, 0x19, 0xe7, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 45 | 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x08, 0x09, 0x2b, 0x09, 0x6f, 0x09, 0x2b, 0x09, 46 | 0xd7, 0x39, 0x1b, 0x39, 0x5f, 0x39, 0x1b, 0x39, 0xd7, 0x29, 0x0a, 0x0a, 0x4e, 0x4e, 0x0a, 0x0a, 47 | 0xd6, 0xd6, 0x1a, 0x1a, 0x5e, 0x5e, 0x1a, 0x1a, 0xd6, 0xd6, 0x0a, 0x0b, 0x6d, 0x4f, 0x6d, 0x0b, 48 | 0x95, 0xb7, 0x95, 0x7b, 0x1d, 0x3f, 0x1d, 0x7b, 0x95, 0xb7, 0x95, 0x6b, 0x0c, 0x0c, 0x0c, 0x0c, 49 | 0x94, 0x94, 0x94, 0x94, 0x1c, 0x1c, 0x1c, 0x1c, 0x94, 0x94, 0x94, 0x94, 0x0c, 0x0d, 0x2f, 0x0d, 50 | 0xd3, 0xb5, 0x97, 0xb5, 0xd3, 0x3d, 0x1f, 0x3d, 0xd3, 0xb5, 0x97, 0xb5, 0xd3, 0x2d, 0x0e, 0x0e, 51 | 0xd2, 0xd2, 0x96, 0x96, 0xd2, 0xd2, 0x1e, 0x1e, 0xd2, 0xd2, 0x96, 0x96, 0xd2, 0xd2, 0x0e, 0x0f, 52 | 0x00, 0x22, 0x00, 0x66, 0x00, 0x22, 0x00, 0xee, 0x00, 0x22, 0x00, 0x66, 0x00, 0x22, 0x00, 0xfe 53 | }; 54 | 55 | std::vector MaskV2PreDef = { 56 | 0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37, 57 | 0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68, 58 | 0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A, 59 | 0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B, 60 | 0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7, 61 | 0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48, 62 | 0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B, 63 | 0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84, 64 | 0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00, 65 | 0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B, 66 | 0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB, 67 | 0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA, 68 | 0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA, 69 | 0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5, 70 | 0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD, 71 | 0xAB, 0x12, 0xB2, 0x13, 0xE8, 0x84, 0xD7, 0xA7, 0x9F, 0x0F, 0x32, 0x4C, 0x55, 0x1D, 0x04, 0x36, 72 | 0x52, 0xDC, 0x03, 0xF3, 0xF9, 0x4E, 0x42, 0xE9, 0x3D, 0x61, 0xEF, 0x7C, 0xB6, 0xB3, 0x93, 0x50, 73 | }; 74 | 75 | uint8_t getMask(size_t pos) { 76 | size_t offset = pos >> 4; 77 | uint8_t value = 0; 78 | while (offset >= 0x11) { 79 | value ^= table1[offset % 272]; 80 | offset >>= 4; 81 | value ^= table2[offset % 272]; 82 | offset >>= 4; 83 | } 84 | 85 | return MaskV2PreDef[pos % 272] ^ value; 86 | } 87 | 88 | std::vector key(17); 89 | bool isVpr = false; 90 | 91 | size_t PreDec(uint8_t* fileData, size_t size, bool iV) { 92 | uint32_t headerLen = *(uint32_t*)(fileData + 0x10); 93 | memcpy(key.data(), (fileData + 0x1C), 0x10); 94 | key[16] = 0; 95 | isVpr = iV; 96 | return headerLen; 97 | } 98 | 99 | void Decrypt(uint8_t* fileData, size_t size, size_t offset) { 100 | for (size_t i = 0; i < size; ++i) { 101 | uint8_t med8 = key[(i + offset) % 17] ^ fileData[i]; 102 | med8 ^= (med8 & 0xf) << 4; 103 | 104 | uint8_t msk8 = getMask(i + offset); 105 | msk8 ^= (msk8 & 0xf) << 4; 106 | fileData[i] = med8 ^ msk8; 107 | 108 | if (isVpr) { 109 | fileData[i] ^= VprMaskDiff[(i + offset) % 17]; 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/decrypt/qmc_cipher.ts: -------------------------------------------------------------------------------- 1 | export interface QmcStreamCipher { 2 | decrypt(buf: Uint8Array, offset: number): void; 3 | } 4 | 5 | export class QmcStaticCipher implements QmcStreamCipher { 6 | //prettier-ignore 7 | private static readonly staticCipherBox: Uint8Array = new Uint8Array([ 8 | 0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00 9 | 0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08 10 | 0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10 11 | 0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18 12 | 0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20 13 | 0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28 14 | 0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30 15 | 0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38 16 | 0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40 17 | 0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48 18 | 0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50 19 | 0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58 20 | 0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60 21 | 0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68 22 | 0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70 23 | 0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78 24 | 0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80 25 | 0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88 26 | 0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90 27 | 0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98 28 | 0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0 29 | 0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8 30 | 0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0 31 | 0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8 32 | 0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0 33 | 0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8 34 | 0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0 35 | 0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8 36 | 0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0 37 | 0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8 38 | 0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0 39 | 0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8 40 | ]) 41 | 42 | public getMask(offset: number) { 43 | if (offset > 0x7fff) offset %= 0x7fff; 44 | return QmcStaticCipher.staticCipherBox[(offset * offset + 27) & 0xff]; 45 | } 46 | 47 | public decrypt(buf: Uint8Array, offset: number) { 48 | for (let i = 0; i < buf.length; i++) { 49 | buf[i] ^= this.getMask(offset + i); 50 | } 51 | } 52 | } 53 | 54 | export class QmcMapCipher implements QmcStreamCipher { 55 | key: Uint8Array; 56 | n: number; 57 | 58 | constructor(key: Uint8Array) { 59 | if (key.length == 0) throw Error('qmc/cipher_map: invalid key size'); 60 | 61 | this.key = key; 62 | this.n = key.length; 63 | } 64 | 65 | private static rotate(value: number, bits: number) { 66 | let rotate = (bits + 4) % 8; 67 | let left = value << rotate; 68 | let right = value >> rotate; 69 | return (left | right) & 0xff; 70 | } 71 | 72 | decrypt(buf: Uint8Array, offset: number): void { 73 | for (let i = 0; i < buf.length; i++) { 74 | buf[i] ^= this.getMask(offset + i); 75 | } 76 | } 77 | 78 | private getMask(offset: number) { 79 | if (offset > 0x7fff) offset %= 0x7fff; 80 | 81 | const idx = (offset * offset + 71214) % this.n; 82 | return QmcMapCipher.rotate(this.key[idx], idx & 0x7); 83 | } 84 | } 85 | 86 | export class QmcRC4Cipher implements QmcStreamCipher { 87 | private static readonly FIRST_SEGMENT_SIZE = 0x80; 88 | private static readonly SEGMENT_SIZE = 5120; 89 | 90 | S: Uint8Array; 91 | N: number; 92 | key: Uint8Array; 93 | hash: number; 94 | 95 | constructor(key: Uint8Array) { 96 | if (key.length == 0) { 97 | throw Error('invalid key size'); 98 | } 99 | 100 | this.key = key; 101 | this.N = key.length; 102 | 103 | // init seed box 104 | this.S = new Uint8Array(this.N); 105 | for (let i = 0; i < this.N; ++i) { 106 | this.S[i] = i & 0xff; 107 | } 108 | let j = 0; 109 | for (let i = 0; i < this.N; ++i) { 110 | j = (this.S[i] + j + this.key[i % this.N]) % this.N; 111 | [this.S[i], this.S[j]] = [this.S[j], this.S[i]]; 112 | } 113 | 114 | // init hash base 115 | this.hash = 1; 116 | for (let i = 0; i < this.N; i++) { 117 | let value = this.key[i]; 118 | 119 | // ignore if key char is '\x00' 120 | if (!value) continue; 121 | 122 | const next_hash = (this.hash * value) >>> 0; 123 | if (next_hash == 0 || next_hash <= this.hash) break; 124 | 125 | this.hash = next_hash; 126 | } 127 | } 128 | 129 | decrypt(buf: Uint8Array, offset: number): void { 130 | let toProcess = buf.length; 131 | let processed = 0; 132 | const postProcess = (len: number): boolean => { 133 | toProcess -= len; 134 | processed += len; 135 | offset += len; 136 | return toProcess == 0; 137 | }; 138 | 139 | // Initial segment 140 | if (offset < QmcRC4Cipher.FIRST_SEGMENT_SIZE) { 141 | const len_segment = Math.min(buf.length, QmcRC4Cipher.FIRST_SEGMENT_SIZE - offset); 142 | this.encFirstSegment(buf.subarray(0, len_segment), offset); 143 | if (postProcess(len_segment)) return; 144 | } 145 | 146 | // align segment 147 | if (offset % QmcRC4Cipher.SEGMENT_SIZE != 0) { 148 | const len_segment = Math.min(QmcRC4Cipher.SEGMENT_SIZE - (offset % QmcRC4Cipher.SEGMENT_SIZE), toProcess); 149 | this.encASegment(buf.subarray(processed, processed + len_segment), offset); 150 | if (postProcess(len_segment)) return; 151 | } 152 | 153 | // Batch process segments 154 | while (toProcess > QmcRC4Cipher.SEGMENT_SIZE) { 155 | this.encASegment(buf.subarray(processed, processed + QmcRC4Cipher.SEGMENT_SIZE), offset); 156 | postProcess(QmcRC4Cipher.SEGMENT_SIZE); 157 | } 158 | 159 | // Last segment (incomplete segment) 160 | if (toProcess > 0) { 161 | this.encASegment(buf.subarray(processed), offset); 162 | } 163 | } 164 | 165 | private encFirstSegment(buf: Uint8Array, offset: number) { 166 | for (let i = 0; i < buf.length; i++) { 167 | buf[i] ^= this.key[this.getSegmentKey(offset + i)]; 168 | } 169 | } 170 | 171 | private encASegment(buf: Uint8Array, offset: number) { 172 | // Initialise a new seed box 173 | const S = this.S.slice(0); 174 | 175 | // Calculate the number of bytes to skip. 176 | // The initial "key" derived from segment id, plus the current offset. 177 | const skipLen = 178 | (offset % QmcRC4Cipher.SEGMENT_SIZE) + this.getSegmentKey(Math.floor(offset / QmcRC4Cipher.SEGMENT_SIZE)); 179 | 180 | // decrypt the block 181 | let j = 0; 182 | let k = 0; 183 | for (let i = -skipLen; i < buf.length; i++) { 184 | j = (j + 1) % this.N; 185 | k = (S[j] + k) % this.N; 186 | [S[k], S[j]] = [S[j], S[k]]; 187 | 188 | if (i >= 0) { 189 | buf[i] ^= S[(S[j] + S[k]) % this.N]; 190 | } 191 | } 192 | } 193 | 194 | private getSegmentKey(id: number): number { 195 | const seed = this.key[id % this.N]; 196 | const idx = Math.floor((this.hash / ((id + 1) * seed)) * 100.0); 197 | return idx % this.N; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/QmcWasm/base64.hpp: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright (c) 2016-2019 Vinnie Falco (vinnie dot falco at gmail dot com) 3 | // 4 | // Distributed under the Boost Software License, Version 1.0. (See accompanying 5 | // file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt) 6 | // 7 | // Official repository: https://github.com/boostorg/beast 8 | // 9 | 10 | /* 11 | Portions from http://www.adp-gmbh.ch/cpp/common/base64.html 12 | Copyright notice: 13 | 14 | base64.cpp and base64.h 15 | 16 | Copyright (C) 2004-2008 Rene Nyffenegger 17 | 18 | This source code is provided 'as-is', without any express or implied 19 | warranty. In no event will the author be held liable for any damages 20 | arising from the use of this software. 21 | 22 | Permission is granted to anyone to use this software for any purpose, 23 | including commercial applications, and to alter it and redistribute it 24 | freely, subject to the following restrictions: 25 | 26 | 1. The origin of this source code must not be misrepresented; you must not 27 | claim that you wrote the original source code. If you use this source code 28 | in a product, an acknowledgment in the product documentation would be 29 | appreciated but is not required. 30 | 31 | 2. Altered source versions must be plainly marked as such, and must not be 32 | misrepresented as being the original source code. 33 | 34 | 3. This notice may not be removed or altered from any source distribution. 35 | 36 | Rene Nyffenegger rene.nyffenegger@adp-gmbh.ch 37 | */ 38 | 39 | #ifndef BASE64_HPP 40 | #define BASE64_HPP 41 | 42 | #include 43 | #include 44 | #include 45 | 46 | namespace base64 { 47 | 48 | /// Returns max chars needed to encode a base64 string 49 | std::size_t constexpr 50 | encoded_size(std::size_t n) 51 | { 52 | return 4 * ((n + 2) / 3); 53 | } 54 | 55 | /// Returns max bytes needed to decode a base64 string 56 | inline 57 | std::size_t constexpr 58 | decoded_size(std::size_t n) 59 | { 60 | return n / 4 * 3; // requires n&3==0, smaller 61 | } 62 | 63 | char const* 64 | get_alphabet() 65 | { 66 | static char constexpr tab[] = { 67 | "ABCDEFGHIJKLMNOP" 68 | "QRSTUVWXYZabcdef" 69 | "ghijklmnopqrstuv" 70 | "wxyz0123456789+/" 71 | }; 72 | return &tab[0]; 73 | } 74 | 75 | signed char const* 76 | get_inverse() 77 | { 78 | static signed char constexpr tab[] = { 79 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 0-15 80 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 16-31 81 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, -1, -1, 63, // 32-47 82 | 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1, -1, -1, -1, -1, -1, // 48-63 83 | -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, // 64-79 84 | 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, -1, // 80-95 85 | -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, // 96-111 86 | 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, -1, -1, -1, -1, -1, // 112-127 87 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 128-143 88 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 144-159 89 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 160-175 90 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 176-191 91 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 192-207 92 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 208-223 93 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, // 224-239 94 | -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 // 240-255 95 | }; 96 | return &tab[0]; 97 | } 98 | 99 | /** Encode a series of octets as a padded, base64 string. 100 | 101 | The resulting string will not be null terminated. 102 | 103 | @par Requires 104 | 105 | The memory pointed to by `out` points to valid memory 106 | of at least `encoded_size(len)` bytes. 107 | 108 | @return The number of characters written to `out`. This 109 | will exclude any null termination. 110 | */ 111 | std::size_t 112 | encode(void* dest, void const* src, std::size_t len) 113 | { 114 | char* out = static_cast(dest); 115 | char const* in = static_cast(src); 116 | auto const tab = base64::get_alphabet(); 117 | 118 | for (auto n = len / 3; n--;) 119 | { 120 | *out++ = tab[(in[0] & 0xfc) >> 2]; 121 | *out++ = tab[((in[0] & 0x03) << 4) + ((in[1] & 0xf0) >> 4)]; 122 | *out++ = tab[((in[2] & 0xc0) >> 6) + ((in[1] & 0x0f) << 2)]; 123 | *out++ = tab[in[2] & 0x3f]; 124 | in += 3; 125 | } 126 | 127 | switch (len % 3) 128 | { 129 | case 2: 130 | *out++ = tab[(in[0] & 0xfc) >> 2]; 131 | *out++ = tab[((in[0] & 0x03) << 4) + ((in[1] & 0xf0) >> 4)]; 132 | *out++ = tab[(in[1] & 0x0f) << 2]; 133 | *out++ = '='; 134 | break; 135 | 136 | case 1: 137 | *out++ = tab[(in[0] & 0xfc) >> 2]; 138 | *out++ = tab[((in[0] & 0x03) << 4)]; 139 | *out++ = '='; 140 | *out++ = '='; 141 | break; 142 | 143 | case 0: 144 | break; 145 | } 146 | 147 | return out - static_cast(dest); 148 | } 149 | 150 | /** Decode a padded base64 string into a series of octets. 151 | 152 | @par Requires 153 | 154 | The memory pointed to by `out` points to valid memory 155 | of at least `decoded_size(len)` bytes. 156 | 157 | @return The number of octets written to `out`, and 158 | the number of characters read from the input string, 159 | expressed as a pair. 160 | */ 161 | std::pair 162 | decode(void* dest, char const* src, std::size_t len) 163 | { 164 | char* out = static_cast(dest); 165 | auto in = reinterpret_cast(src); 166 | unsigned char c3[3], c4[4]; 167 | int i = 0; 168 | int j = 0; 169 | 170 | auto const inverse = base64::get_inverse(); 171 | 172 | while (len-- && *in != '=') 173 | { 174 | auto const v = inverse[*in]; 175 | if (v == -1) 176 | break; 177 | ++in; 178 | c4[i] = v; 179 | if (++i == 4) 180 | { 181 | c3[0] = (c4[0] << 2) + ((c4[1] & 0x30) >> 4); 182 | c3[1] = ((c4[1] & 0xf) << 4) + ((c4[2] & 0x3c) >> 2); 183 | c3[2] = ((c4[2] & 0x3) << 6) + c4[3]; 184 | 185 | for (i = 0; i < 3; i++) 186 | *out++ = c3[i]; 187 | i = 0; 188 | } 189 | } 190 | 191 | if (i) 192 | { 193 | c3[0] = (c4[0] << 2) + ((c4[1] & 0x30) >> 4); 194 | c3[1] = ((c4[1] & 0xf) << 4) + ((c4[2] & 0x3c) >> 2); 195 | c3[2] = ((c4[2] & 0x3) << 6) + c4[3]; 196 | 197 | for (j = 0; j < i - 1; j++) 198 | *out++ = c3[j]; 199 | } 200 | 201 | return { out - static_cast(dest), 202 | in - reinterpret_cast(src) }; 203 | } 204 | 205 | } // base64 206 | 207 | #endif 208 | -------------------------------------------------------------------------------- /src/decrypt/ncm.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AudioMimeType, 3 | BytesHasPrefix, 4 | GetArrayBuffer, 5 | GetImageFromURL, 6 | GetMetaFromFile, 7 | IMusicMeta, 8 | SniffAudioExt, 9 | WriteMetaToFlac, 10 | WriteMetaToMp3, 11 | } from '@/decrypt/utils'; 12 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 13 | import jimp from 'jimp'; 14 | 15 | import AES from 'crypto-js/aes'; 16 | import PKCS7 from 'crypto-js/pad-pkcs7'; 17 | import ModeECB from 'crypto-js/mode-ecb'; 18 | import WordArray from 'crypto-js/lib-typedarrays'; 19 | import Base64 from 'crypto-js/enc-base64'; 20 | import EncUTF8 from 'crypto-js/enc-utf8'; 21 | import EncHex from 'crypto-js/enc-hex'; 22 | 23 | import { DecryptResult } from '@/decrypt/entity'; 24 | 25 | const CORE_KEY = EncHex.parse('687a4852416d736f356b496e62617857'); 26 | const META_KEY = EncHex.parse('2331346C6A6B5F215C5D2630553C2728'); 27 | const MagicHeader = [0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d]; 28 | 29 | export async function Decrypt(file: File, raw_filename: string, _: string): Promise { 30 | return new NcmDecrypt(await GetArrayBuffer(file), raw_filename).decrypt(); 31 | } 32 | 33 | interface NcmMusicMeta { 34 | //musicId: number 35 | musicName?: string; 36 | artist?: Array[]; 37 | format?: string; 38 | album?: string; 39 | albumPic?: string; 40 | } 41 | 42 | interface NcmDjMeta { 43 | mainMusic: NcmMusicMeta; 44 | } 45 | 46 | class NcmDecrypt { 47 | raw: ArrayBuffer; 48 | view: DataView; 49 | offset: number = 0; 50 | filename: string; 51 | format: string = ''; 52 | mime: string = ''; 53 | audio?: Uint8Array; 54 | blob?: Blob; 55 | oriMeta?: NcmMusicMeta; 56 | newMeta?: IMusicMeta; 57 | image?: { mime: string; buffer: ArrayBuffer; url: string }; 58 | 59 | constructor(buf: ArrayBuffer, filename: string) { 60 | const prefix = new Uint8Array(buf, 0, 8); 61 | if (!BytesHasPrefix(prefix, MagicHeader)) throw Error('此ncm文件已损坏'); 62 | this.offset = 10; 63 | this.raw = buf; 64 | this.view = new DataView(buf); 65 | this.filename = filename; 66 | } 67 | 68 | _getKeyData(): Uint8Array { 69 | const keyLen = this.view.getUint32(this.offset, true); 70 | this.offset += 4; 71 | const cipherText = new Uint8Array(this.raw, this.offset, keyLen).map((uint8) => uint8 ^ 0x64); 72 | this.offset += keyLen; 73 | 74 | const plainText = AES.decrypt( 75 | // @ts-ignore 76 | { ciphertext: WordArray.create(cipherText) }, 77 | CORE_KEY, 78 | { mode: ModeECB, padding: PKCS7 }, 79 | ); 80 | 81 | const result = new Uint8Array(plainText.sigBytes); 82 | 83 | const words = plainText.words; 84 | const sigBytes = plainText.sigBytes; 85 | for (let i = 0; i < sigBytes; i++) { 86 | result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff; 87 | } 88 | 89 | return result.slice(17); 90 | } 91 | 92 | _getKeyBox(): Uint8Array { 93 | const keyData = this._getKeyData(); 94 | const box = new Uint8Array(Array(256).keys()); 95 | 96 | const keyDataLen = keyData.length; 97 | 98 | let j = 0; 99 | 100 | for (let i = 0; i < 256; i++) { 101 | j = (box[i] + j + keyData[i % keyDataLen]) & 0xff; 102 | [box[i], box[j]] = [box[j], box[i]]; 103 | } 104 | 105 | return box.map((_, i, arr) => { 106 | i = (i + 1) & 0xff; 107 | const si = arr[i]; 108 | const sj = arr[(i + si) & 0xff]; 109 | return arr[(si + sj) & 0xff]; 110 | }); 111 | } 112 | 113 | _getMetaData(): NcmMusicMeta { 114 | const metaDataLen = this.view.getUint32(this.offset, true); 115 | this.offset += 4; 116 | if (metaDataLen === 0) return {}; 117 | 118 | const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen).map((data) => data ^ 0x63); 119 | this.offset += metaDataLen; 120 | 121 | WordArray.create(); 122 | const plainText = AES.decrypt( 123 | // @ts-ignore 124 | { 125 | ciphertext: Base64.parse( 126 | // @ts-ignore 127 | WordArray.create(cipherText.slice(22)).toString(EncUTF8), 128 | ), 129 | }, 130 | META_KEY, 131 | { mode: ModeECB, padding: PKCS7 }, 132 | ).toString(EncUTF8); 133 | 134 | const labelIndex = plainText.indexOf(':'); 135 | let result: NcmMusicMeta; 136 | if (plainText.slice(0, labelIndex) === 'dj') { 137 | const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1)); 138 | result = tmp.mainMusic; 139 | } else { 140 | result = JSON.parse(plainText.slice(labelIndex + 1)); 141 | } 142 | if (!!result.albumPic) { 143 | result.albumPic = result.albumPic.replace('http://', 'https://') + '?param=500y500'; 144 | } 145 | return result; 146 | } 147 | 148 | _getAudio(keyBox: Uint8Array): Uint8Array { 149 | this.offset += this.view.getUint32(this.offset + 5, true) + 13; 150 | const audioData = new Uint8Array(this.raw, this.offset); 151 | let lenAudioData = audioData.length; 152 | for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff]; 153 | return audioData; 154 | } 155 | 156 | async _buildMeta() { 157 | if (!this.oriMeta) throw Error('invalid sequence'); 158 | 159 | const info = GetMetaFromFile(this.filename, this.oriMeta.musicName); 160 | 161 | // build artists 162 | let artists: string[] = []; 163 | if (!!this.oriMeta.artist) { 164 | this.oriMeta.artist.forEach((arr) => artists.push(arr[0])); 165 | } 166 | 167 | if (artists.length === 0 && !!info.artist) { 168 | artists = info.artist 169 | .split(',') 170 | .map((val) => val.trim()) 171 | .filter((val) => val != ''); 172 | } 173 | 174 | if (this.oriMeta.albumPic) 175 | try { 176 | this.image = await GetImageFromURL(this.oriMeta.albumPic); 177 | while (this.image && this.image.buffer.byteLength >= 1 << 24) { 178 | let img = await jimp.read(Buffer.from(this.image.buffer)); 179 | await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO); 180 | this.image.buffer = await img.getBufferAsync('image/jpeg'); 181 | } 182 | } catch (e) { 183 | console.log('get cover image failed', e); 184 | } 185 | 186 | this.newMeta = { title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer }; 187 | } 188 | 189 | async _writeMeta() { 190 | if (!this.audio || !this.newMeta) throw Error('invalid sequence'); 191 | 192 | if (!this.blob) this.blob = new Blob([this.audio], { type: this.mime }); 193 | const ori = await metaParseBlob(this.blob); 194 | 195 | let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title; 196 | if (shouldWrite || this.newMeta.picture) { 197 | if (this.format === 'mp3') { 198 | this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori); 199 | } else if (this.format === 'flac') { 200 | this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori); 201 | } else { 202 | console.info(`writing meta for ${this.format} is not being supported for now`); 203 | return; 204 | } 205 | this.blob = new Blob([this.audio], { type: this.mime }); 206 | } 207 | } 208 | 209 | gatherResult(): DecryptResult { 210 | if (!this.newMeta || !this.blob) throw Error('bad sequence'); 211 | return { 212 | title: this.newMeta.title, 213 | artist: this.newMeta.artists?.join('; '), 214 | ext: this.format, 215 | album: this.newMeta.album, 216 | picture: this.image?.url, 217 | file: URL.createObjectURL(this.blob), 218 | blob: this.blob, 219 | mime: this.mime, 220 | }; 221 | } 222 | 223 | async decrypt() { 224 | const keyBox = this._getKeyBox(); 225 | this.oriMeta = this._getMetaData(); 226 | this.audio = this._getAudio(keyBox); 227 | this.format = this.oriMeta.format || SniffAudioExt(this.audio); 228 | this.mime = AudioMimeType[this.format]; 229 | await this._buildMeta(); 230 | try { 231 | await this._writeMeta(); 232 | } catch (e) { 233 | console.warn('write meta data failed', e); 234 | } 235 | return this.gatherResult(); 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /src/decrypt/utils.ts: -------------------------------------------------------------------------------- 1 | import { IAudioMetadata } from 'music-metadata-browser'; 2 | import ID3Writer from 'browser-id3-writer'; 3 | import MetaFlac from 'metaflac-js'; 4 | 5 | export const split_regex = /[ ]?[,;/_、][ ]?/; 6 | 7 | export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43]; 8 | export const MP3_HEADER = [0x49, 0x44, 0x33]; 9 | export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53]; 10 | export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70]; 11 | //prettier-ignore 12 | export const WMA_HEADER = [ 13 | 0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 14 | 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, 15 | ]; 16 | export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]; 17 | export const AAC_HEADER = [0xff, 0xf1]; 18 | export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38]; 19 | 20 | export const AudioMimeType: { [key: string]: string } = { 21 | mp3: 'audio/mpeg', 22 | flac: 'audio/flac', 23 | m4a: 'audio/mp4', 24 | ogg: 'audio/ogg', 25 | wma: 'audio/x-ms-wma', 26 | wav: 'audio/x-wav', 27 | dff: 'audio/x-dff', 28 | }; 29 | 30 | export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean { 31 | if (prefix.length > data.length) return false; 32 | return prefix.every((val, idx) => { 33 | return val === data[idx]; 34 | }); 35 | } 36 | 37 | export function BytesEqual(a: Uint8Array, b: Uint8Array): boolean { 38 | if (a.length !== b.length) return false; 39 | return a.every((val, idx) => { 40 | return val === b[idx]; 41 | }); 42 | } 43 | 44 | export function SniffAudioExt(data: Uint8Array, fallback_ext: string = 'mp3'): string { 45 | if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3'; 46 | if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac'; 47 | if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg'; 48 | if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a'; 49 | if (BytesHasPrefix(data, WAV_HEADER)) return 'wav'; 50 | if (BytesHasPrefix(data, WMA_HEADER)) return 'wma'; 51 | if (BytesHasPrefix(data, AAC_HEADER)) return 'aac'; 52 | if (BytesHasPrefix(data, DFF_HEADER)) return 'dff'; 53 | return fallback_ext; 54 | } 55 | 56 | export function GetArrayBuffer(obj: Blob): Promise { 57 | if (!!obj.arrayBuffer) return obj.arrayBuffer(); 58 | return new Promise((resolve, reject) => { 59 | const reader = new FileReader(); 60 | reader.onload = (e) => { 61 | const rs = e.target?.result; 62 | if (!rs) { 63 | reject('read file failed'); 64 | } else { 65 | resolve(rs as ArrayBuffer); 66 | } 67 | }; 68 | reader.readAsArrayBuffer(obj); 69 | }); 70 | } 71 | 72 | export function GetCoverFromFile(metadata: IAudioMetadata): string { 73 | if (metadata.common?.picture && metadata.common.picture.length > 0) { 74 | return URL.createObjectURL( 75 | new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }), 76 | ); 77 | } 78 | return ''; 79 | } 80 | 81 | export interface IMusicMetaBasic { 82 | title: string; 83 | artist?: string; 84 | } 85 | 86 | export function GetMetaFromFile( 87 | filename: string, 88 | exist_title?: string, 89 | exist_artist?: string, 90 | separator = '-', 91 | ): IMusicMetaBasic { 92 | const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist }; 93 | 94 | const items = filename.split(separator); 95 | if (items.length > 1) { 96 | //由文件名和原metadata共同决定歌手tag(有时从文件名看有多个歌手,而metadata只有一个) 97 | if (!meta.artist || meta.artist.split(split_regex).length < items[0].trim().split(split_regex).length) meta.artist = items[0].trim(); 98 | if (!meta.title) meta.title = items[1].trim(); 99 | } else if (items.length === 1) { 100 | if (!meta.title) meta.title = items[0].trim(); 101 | } 102 | return meta; 103 | } 104 | 105 | export async function GetImageFromURL( 106 | src: string, 107 | ): Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> { 108 | try { 109 | const resp = await fetch(src); 110 | const mime = resp.headers.get('Content-Type'); 111 | if (mime?.startsWith('image/')) { 112 | const buffer = await resp.arrayBuffer(); 113 | const url = URL.createObjectURL(new Blob([buffer], { type: mime })); 114 | return { buffer, url, mime }; 115 | } 116 | } catch (e) { 117 | console.warn(e); 118 | } 119 | } 120 | 121 | export interface IMusicMeta { 122 | title: string; 123 | artists?: string[]; 124 | album?: string; 125 | albumartist?: string; 126 | genre?: string[]; 127 | picture?: ArrayBuffer; 128 | picture_desc?: string; 129 | } 130 | 131 | export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { 132 | const writer = new ID3Writer(audioData); 133 | 134 | // reserve original data 135 | const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []; 136 | frames.forEach((frame) => { 137 | if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') { 138 | try { 139 | writer.setFrame(frame.id, frame.value); 140 | } catch (e) { 141 | console.warn(`failed to write ID3 tag '${frame.id}'`); 142 | } 143 | } 144 | }); 145 | 146 | const old = original.common; 147 | writer 148 | .setFrame('TPE1', old?.artists || info.artists || []) 149 | .setFrame('TIT2', old?.title || info.title) 150 | .setFrame('TALB', old?.album || info.album || ''); 151 | if (info.picture) { 152 | writer.setFrame('APIC', { 153 | type: 3, 154 | data: info.picture, 155 | description: info.picture_desc || '', 156 | }); 157 | } 158 | return writer.addTag(); 159 | } 160 | 161 | export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { 162 | const writer = new MetaFlac(audioData); 163 | const old = original.common; 164 | if (!old.title && !old.album && old.artists) { 165 | writer.setTag('TITLE=' + info.title); 166 | writer.setTag('ALBUM=' + info.album); 167 | if (info.artists) { 168 | writer.removeTag('ARTIST'); 169 | info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist)); 170 | } 171 | } 172 | 173 | if (info.picture) { 174 | writer.importPictureFromBuffer(Buffer.from(info.picture)); 175 | } 176 | return writer.save(); 177 | } 178 | 179 | export function RewriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { 180 | const writer = new ID3Writer(audioData); 181 | 182 | // preserve original data 183 | const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []; 184 | frames.forEach((frame) => { 185 | if (frame.id !== 'TPE1' 186 | && frame.id !== 'TIT2' 187 | && frame.id !== 'TALB' 188 | && frame.id !== 'TPE2' 189 | && frame.id !== 'TCON' 190 | ) { 191 | try { 192 | writer.setFrame(frame.id, frame.value); 193 | } catch (e) { 194 | throw new Error(`failed to write ID3 tag '${frame.id}'`); 195 | } 196 | } 197 | }); 198 | 199 | const old = original.common; 200 | writer 201 | .setFrame('TPE1', info?.artists || old.artists || []) 202 | .setFrame('TIT2', info?.title || old.title) 203 | .setFrame('TALB', info?.album || old.album || '') 204 | .setFrame('TPE2', info?.albumartist || old.albumartist || '') 205 | .setFrame('TCON', info?.genre || old.genre || []); 206 | if (info.picture) { 207 | writer.setFrame('APIC', { 208 | type: 3, 209 | data: info.picture, 210 | description: info.picture_desc || '', 211 | }); 212 | } 213 | return writer.addTag(); 214 | } 215 | 216 | export function RewriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { 217 | const writer = new MetaFlac(audioData); 218 | const old = original.common; 219 | if (info.title) { 220 | if (old.title) { 221 | writer.removeTag('TITLE'); 222 | } 223 | writer.setTag('TITLE=' + info.title); 224 | } 225 | if (info.album) { 226 | if (old.album) { 227 | writer.removeTag('ALBUM'); 228 | } 229 | writer.setTag('ALBUM=' + info.album); 230 | } 231 | if (info.albumartist) { 232 | if (old.albumartist) { 233 | writer.removeTag('ALBUMARTIST'); 234 | } 235 | writer.setTag('ALBUMARTIST=' + info.albumartist); 236 | } 237 | if (info.artists) { 238 | if (old.artists) { 239 | writer.removeTag('ARTIST'); 240 | } 241 | info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist)); 242 | } 243 | if (info.genre) { 244 | if (old.genre) { 245 | writer.removeTag('GENRE'); 246 | } 247 | info.genre.forEach((singlegenre) => writer.setTag('GENRE=' + singlegenre)); 248 | } 249 | 250 | if (info.picture) { 251 | writer.importPictureFromBuffer(Buffer.from(info.picture)); 252 | } 253 | return writer.save(); 254 | } 255 | 256 | export function SplitFilename(n: string): { name: string; ext: string } { 257 | const pos = n.lastIndexOf('.'); 258 | return { 259 | ext: n.substring(pos + 1).toLowerCase(), 260 | name: n.substring(0, pos), 261 | }; 262 | } 263 | -------------------------------------------------------------------------------- /src/view/Home.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 256 | --------------------------------------------------------------------------------