├── .browserslistrc ├── .drone.yml ├── .gitignore ├── .gitlab-ci.yml ├── .gitlab └── ISSUE_TEMPLATE │ ├── bug-crypto-guided.yaml │ ├── bug-report.md │ └── new-feature.md ├── .nvmrc ├── .prettierrc.js ├── Dockerfile ├── LICENSE ├── README.md ├── babel.config.js ├── extension-manifest.json ├── jest.config.js ├── make-extension.js ├── package-lock.json ├── package.json ├── patches └── threads+1.7.0.patch ├── postcss.config.js ├── public ├── favicon.ico ├── img │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon-152x152.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── msapplication-icon-144x144.png │ │ └── safari-pinned-tab.svg ├── index.html └── loader.js ├── scripts ├── build-and-package.sh └── upload-packages.sh ├── src ├── App.vue ├── __test__ │ └── setup_jest.js ├── component │ ├── ConfigDialog.vue │ ├── EditDialog.vue │ ├── FileSelector.vue │ ├── PreviewTable.vue │ └── Ruby.vue ├── decrypt │ ├── __test__ │ │ ├── fixture │ │ │ ├── joox_1.bin │ │ │ └── qmc_cache_expected.bin │ │ └── joox.test.ts │ ├── entity.ts │ ├── index.ts │ ├── joox.ts │ ├── kgm.ts │ ├── kgm_wasm.ts │ ├── kwm.ts │ ├── mg3d.ts │ ├── ncm.ts │ ├── ncmcache.ts │ ├── qmc.ts │ ├── qmc_wasm.ts │ ├── qmccache.ts │ ├── raw.ts │ ├── tm.ts │ ├── utils.ts │ ├── ximalaya.ts │ └── xm.ts ├── extension │ ├── popup.html │ └── popup.js ├── main.ts ├── registerServiceWorker.js ├── scss │ ├── _dark-mode.scss │ ├── _element-ui-overwrite.scss │ ├── _gaps.scss │ ├── _utility.scss │ ├── _variables.scss │ └── unlock-music.scss ├── shims-browser-id3-writer.d.ts ├── shims-fs.d.ts ├── shims-tsx.d.ts ├── shims-vue.d.ts ├── utils │ ├── MergeUint8Array.ts │ ├── __mocks__ │ │ ├── qm_meta.ts │ │ └── storage.ts │ ├── api.ts │ ├── qm_meta.ts │ ├── storage.ts │ ├── storage │ │ ├── BaseStorage.ts │ │ ├── BrowserNativeStorage.ts │ │ ├── ChromeExtensionStorage.ts │ │ ├── InMemoryStorage.ts │ │ └── StorageFactory.ts │ ├── utils.ts │ └── worker.ts └── view │ └── Home.vue ├── testdata ├── mflac0_rc4_key.bin ├── mflac0_rc4_key_raw.bin ├── mflac0_rc4_raw.bin ├── mflac0_rc4_suffix.bin ├── mflac0_rc4_target.bin ├── mflac_map_key.bin ├── mflac_map_key_raw.bin ├── mflac_map_raw.bin ├── mflac_map_suffix.bin ├── mflac_map_target.bin ├── mflac_rc4_key.bin ├── mflac_rc4_key_raw.bin ├── mflac_rc4_raw.bin ├── mflac_rc4_suffix.bin ├── mflac_rc4_target.bin ├── mgg_map_key.bin ├── mgg_map_key_raw.bin ├── mgg_map_raw.bin ├── mgg_map_suffix.bin ├── mgg_map_target.bin ├── qmc0_static_raw.bin ├── qmc0_static_suffix.bin └── qmc0_static_target.bin ├── tsconfig.json └── vue.config.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: docker 4 | name: default 5 | 6 | steps: 7 | - name: build 8 | image: node:16.18-bullseye 9 | commands: 10 | - apt-get update 11 | - apt-get install -y jq zip 12 | - npm ci 13 | - npm run test 14 | - ./scripts/build-and-package.sh legacy 15 | - ./scripts/build-and-package.sh extension 16 | - ./scripts/build-and-package.sh modern 17 | 18 | - name: upload artifact 19 | image: node:16.18-bullseye 20 | environment: 21 | DRONE_GITEA_SERVER: https://git.unlock-music.dev 22 | GITEA_API_KEY: 23 | from_secret: GITEA_API_KEY 24 | commands: 25 | - ./scripts/upload-packages.sh 26 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitlab/ISSUE_TEMPLATE/bug-crypto-guided.yaml: -------------------------------------------------------------------------------- 1 | name: 解码错误报告 (填表) 2 | about: 遇到文件解码失败的问题请选择该项。 3 | title: '[Bug/Crypto] ' 4 | labels: 5 | - bug 6 | - crypto 7 | body: 8 | - type: textarea 9 | id: what-happened 10 | attributes: 11 | label: 错误描述 12 | description: 请描述你所遇到的问题,以及你期待的行为。 13 | placeholder: '' 14 | value: '' 15 | validations: 16 | required: true 17 | - type: dropdown 18 | id: version 19 | attributes: 20 | label: Unlock Music 版本 21 | description: | 22 | 能够重现错误的版本,版本号通常在页面底部。 23 | 如果不确定,请升级到最新版确认问题是否解决。 24 | multiple: true 25 | options: 26 | - 1.10.5 (仓库最新) 27 | - 1.10.3 (官方 DEMO) 28 | - 其它(请在错误描述中指定) 29 | validations: 30 | required: true 31 | - type: dropdown 32 | id: browsers 33 | attributes: 34 | label: 产生错误的浏览器 35 | multiple: true 36 | options: 37 | - 火狐 / Firefox 38 | - Chrome 39 | - Safari 40 | - 其它基于 Chromium 的浏览器 (Edge、Brave、Opera 等) 41 | - type: dropdown 42 | id: music-platform 43 | attributes: 44 | label: 音乐平台 45 | description: | 46 | 如果需要报告多个平台的问题,请每个平台提交一个新的 Issue。 47 | 请注意:播放器缓存文件不属于该项目支持的文件类型。 48 | multiple: false 49 | options: 50 | - 其它 (请在错误描述指定) 51 | - QQ 音乐 52 | - Joox (QQ 音乐海外版) 53 | - 虾米音乐 54 | - 网易云音乐 55 | - 酷我音乐 56 | - 酷狗音乐 57 | - 喜马拉雅 58 | - 咪咕 3D 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: logs 63 | attributes: 64 | label: 日志信息 65 | description: 如果有,请提供浏览器开发者控制台(Console)的错误日志: 66 | render: text 67 | - type: checkboxes 68 | id: terms 69 | attributes: 70 | label: 我已经阅读并确认下述内容 71 | description: '' 72 | options: 73 | - label: 我已经检索过 Issue 列表,并确认这是一个为报告过的问题。 74 | required: true 75 | - label: 我有证据表明这是程序导致的问题(如不确认,可以通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论) 76 | required: true 77 | -------------------------------------------------------------------------------- /.gitlab/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: "错误报告" 4 | about: "报告 Bug 以帮助改进程序,非填表。" 5 | title: "[BUG] " 6 | labels: 7 | 8 | - bug 9 | 10 | --- 11 | 12 | * 请按照此模板填写,否则可能立即被关闭 13 | 14 | - [x] 我确认已经搜索过Issue不存并确认相同的Issue 15 | - [x] 我有证据表明这是程序导致的问题(如不确认,可以通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论) 16 | 17 | ## Bug描述 18 | 19 | 简要地复述你遇到的Bug 20 | 21 | ## 复现方法 22 | 23 | 描述复现方法,必要时请提供样本文件 24 | 25 | ## 程序截图或浏览器开发者控制台(Console)的报错信息 26 | 27 | 如果可以请提供二者之一 28 | 29 | ## 环境信息 30 | 31 | - 操作系统和浏览器: 32 | - 程序版本: 33 | - 网页版的地址(如果为非官方部署请注明): 34 | 35 | 注意:如果需要会员才能获取该资源,你可能也需要作为附件提交。 36 | 37 | ## 附加信息 38 | 39 | 如果有,请提供其他能够帮助确认问题的信息到下方: 40 | 41 | -------------------------------------------------------------------------------- /.gitlab/ISSUE_TEMPLATE/new-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | name: "新功能" 4 | about: "对于程序新的想法或建议" 5 | title: "[新功能] " 6 | labels: 7 | 8 | - enhancement 9 | 10 | --- 11 | 12 | 13 | 14 | 15 | ## 背景和说明 16 | 17 | 18 | 19 | 20 | ## 实现途径 21 | 22 | - 如果没有设计方案,请简要描述实现思路 23 | - 如果你没有任何的实现思路,请通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论 24 | 25 | 26 | ## 附加信息 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.1 2 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unlock Music 音乐解锁 2 | 3 | [![Build Status](https://ci.unlock-music.dev/api/badges/um/web/status.svg)](https://ci.unlock-music.dev/um/web) 4 | 5 | - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. 6 | - Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。 7 | - Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。 8 | - 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入! 9 | - CI 自动构建已经部署,可以在 [UM-Packages] 下载 10 | 11 | [授权协议]: https://git.unlock-music.dev/um/web/src/branch/master/LICENSE 12 | [unlock-music/cli]: https://git.unlock-music.dev/um/cli 13 | [`@unlock_music_chat`]: https://t.me/unlock_music_chat 14 | [UM-Packages]: https://git.unlock-music.dev/um/-/packages/generic/web-build/ 15 | 16 | ## 特性 17 | 18 | ### 支持的格式 19 | 20 | - [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm) 21 | - [x] Moo 音乐格式 (.bkcmp3/.bkcflac/...) 22 | - [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6) 23 | - [x] QQ 音乐新格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl) 24 | - [x] QQ 音乐海外版JOOX Music (.ofl_en) 25 | - [x] 网易云音乐格式 (.ncm) 26 | - [x] 虾米音乐格式 (.xm) 27 | - [x] 酷我音乐格式 (.kwm) 28 | - [x] 酷狗音乐格式 (.kgm/.vpr) 29 | - [x] Android版喜马拉雅文件格式 (.x2m/.x3m) 30 | - [x] 咪咕音乐格式 (.mg3d) 31 | 32 | ### 其他特性 33 | 34 | - [x] 在浏览器中解锁 35 | - [x] 拖放文件 36 | - [x] 批量解锁 37 | - [x] 渐进式 Web 应用 (PWA) 38 | - [x] 多线程 39 | - [x] 写入和编辑元信息与专辑封面 40 | 41 | ## 使用方法 42 | 43 | ### 使用预构建版本 44 | 45 | - 从 [Release] 或 [CI 构建][UM-Packages] 下载预构建的版本 46 | - :warning: 本地使用请下载`legacy版本`(`modern版本`只能通过 **http(s)协议** 访问) 47 | - 解压缩后即可部署或本地使用(**请勿直接运行源代码**) 48 | 49 | [release]: https://git.unlock-music.dev/um/web/releases/latest 50 | 51 | ### 自行构建 52 | 53 | - 环境要求 54 | - nodejs (v16.x) 55 | - npm 56 | 57 | 1. 获取项目源代码后安装相关依赖: 58 | 59 | ```sh 60 | npm install 61 | npm ci 62 | ``` 63 | 64 | 2. 然后进行构建: 65 | 66 | ```sh 67 | npm run build 68 | ``` 69 | 70 | - 构建后的产物可以在 `dist` 目录找到。 71 | - 如果是用于开发,可以执行 `npm run serve`。 72 | 73 | 3. 如需构建浏览器扩展,构建成功后还需要执行: 74 | 75 | ```sh 76 | npm run make-extension 77 | ``` 78 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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": [ 10 | "storage" 11 | ], 12 | "offline_enabled": true, 13 | "options_page": "./index.html", 14 | "homepage_url": "https://git.unlock-music.dev/um/web", 15 | "browser_action": { 16 | "default_popup": "./popup.html" 17 | } 18 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "unlock-music", 3 | "version": "1.10.6", 4 | "ext_build": 0, 5 | "updateInfo": "修正文件过小的情况下酷狗 / QQ解密错误问题", 6 | "license": "MIT", 7 | "description": "Unlock encrypted music file in browser.", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://git.unlock-music.dev/um/web" 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 | "@unlock-music/joox-crypto": "^0.0.1-R5", 25 | "@xhacker/kgmwasm": "^1.0.0", 26 | "@xhacker/qmcwasm": "^1.0.0", 27 | "base64-js": "^1.5.1", 28 | "browser-id3-writer": "^4.4.0", 29 | "core-js": "^3.16.0", 30 | "crypto-js": "^4.1.1", 31 | "element-ui": "^2.15.5", 32 | "iconv-lite": "^0.6.3", 33 | "jimp": "^0.16.1", 34 | "metaflac-js": "^1.0.5", 35 | "music-metadata": "7.9.0", 36 | "music-metadata-browser": "2.2.7", 37 | "register-service-worker": "^1.7.2", 38 | "threads": "^1.6.5", 39 | "vue": "^2.6.14" 40 | }, 41 | "devDependencies": { 42 | "@types/crypto-js": "^4.0.2", 43 | "@types/jest": "^27.0.3", 44 | "@vue/cli-plugin-babel": "^4.5.13", 45 | "@vue/cli-plugin-pwa": "^4.5.13", 46 | "@vue/cli-plugin-typescript": "^4.5.13", 47 | "@vue/cli-service": "^4.5.13", 48 | "babel-plugin-component": "^1.1.1", 49 | "jest": "^27.4.5", 50 | "patch-package": "^6.4.7", 51 | "prettier": "2.5.1", 52 | "sass": "^1.38.1", 53 | "sass-loader": "^10.2.0", 54 | "semver": "^7.3.5", 55 | "threads-plugin": "^1.4.0", 56 | "typescript": "^4.5.4", 57 | "vue-cli-plugin-element": "^1.0.1", 58 | "vue-template-compiler": "^2.6.14" 59 | } 60 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/favicon.ico -------------------------------------------------------------------------------- /public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 3 | 5 | 6 | 7 | 8 | 9 | 11 | 13 | 15 | 17 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 音乐解锁 9 | 10 | 11 | 64 | 65 | 66 | 67 |
68 |
69 | 72 |

请勿直接运行源代码!

73 | 78 | 85 |
86 |
87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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" 2 | 3 | 4 | 5 | 6 | 7 |
8 | 音乐解锁({{ version }}) 9 | :移除已购音乐的加密保护。 10 | 使用提示 11 |
12 |
13 | 目前支持 网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm) 14 | 更多。 15 |
16 |
17 | 18 | Copyright © 2019 - {{ new Date().getFullYear() }} MengYX 19 | 音乐解锁使用 20 | MIT许可协议 21 | 开放源代码 22 |
23 |
24 |
25 | 26 | 27 | 91 | 92 | 95 | -------------------------------------------------------------------------------- /src/__test__/setup_jest.js: -------------------------------------------------------------------------------- 1 | // Polyfill for node. 2 | global.Blob = global.Blob || require("node:buffer").Blob; 3 | -------------------------------------------------------------------------------- /src/component/ConfigDialog.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 49 | 50 | 106 | -------------------------------------------------------------------------------- /src/component/EditDialog.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 78 | 79 | 164 | -------------------------------------------------------------------------------- /src/component/FileSelector.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 92 | -------------------------------------------------------------------------------- /src/component/PreviewTable.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/component/Ruby.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 19 | -------------------------------------------------------------------------------- /src/decrypt/__test__/fixture/joox_1.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/src/decrypt/__test__/fixture/joox_1.bin -------------------------------------------------------------------------------- /src/decrypt/__test__/fixture/qmc_cache_expected.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/src/decrypt/__test__/fixture/qmc_cache_expected.bin -------------------------------------------------------------------------------- /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://example.unlock-music.dev/', 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/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/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/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/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 | 13 | //prettier-ignore 14 | const VprHeader = [ 15 | 0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, 16 | 0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31 17 | ] 18 | //prettier-ignore 19 | const KgmHeader = [ 20 | 0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, 21 | 0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14 22 | ] 23 | 24 | export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise { 25 | const oriData = await GetArrayBuffer(file); 26 | if (raw_ext === 'vpr') { 27 | if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!'); 28 | } else { 29 | if (!BytesHasPrefix(new Uint8Array(oriData), KgmHeader)) throw Error('Not a valid kgm(a) file!'); 30 | } 31 | let musicDecoded = new Uint8Array(); 32 | if (globalThis.WebAssembly) { 33 | const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext); 34 | if (kgmDecrypted.success) { 35 | musicDecoded = kgmDecrypted.data; 36 | console.log('kgm wasm decoder suceeded'); 37 | } else { 38 | throw new Error(kgmDecrypted.error || '(unknown error)'); 39 | } 40 | } 41 | 42 | const ext = SniffAudioExt(musicDecoded); 43 | const mime = AudioMimeType[ext]; 44 | let musicBlob = new Blob([musicDecoded], { type: mime }); 45 | const musicMeta = await metaParseBlob(musicBlob); 46 | const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || "")); 47 | return { 48 | album: musicMeta.common.album, 49 | picture: GetCoverFromFile(musicMeta), 50 | file: URL.createObjectURL(musicBlob), 51 | blob: musicBlob, 52 | ext, 53 | mime, 54 | title, 55 | artist, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/decrypt/kgm_wasm.ts: -------------------------------------------------------------------------------- 1 | import { KgmCrypto } from '@xhacker/kgmwasm/KgmWasmBundle'; 2 | import KgmCryptoModule from '@xhacker/kgmwasm/KgmWasmBundle'; 3 | import { MergeUint8Array } from '@/utils/MergeUint8Array'; 4 | 5 | // 每次可以处理 2M 的数据 6 | const DECRYPTION_BUF_SIZE = 2 *1024 * 1024; 7 | 8 | export interface KGMDecryptionResult { 9 | success: boolean; 10 | data: Uint8Array; 11 | error: string; 12 | } 13 | 14 | /** 15 | * 解密一个 KGM 加密的文件。 16 | * 17 | * 如果检测并解密成功,返回解密后的 Uint8Array 数据。 18 | * @param {ArrayBuffer} kgmBlob 读入的文件 Blob 19 | */ 20 | export async function DecryptKgmWasm(kgmBlob: ArrayBuffer, ext: string): Promise { 21 | const result: KGMDecryptionResult = { success: false, data: new Uint8Array(), error: '' }; 22 | 23 | // 初始化模组 24 | let KgmCryptoObj: KgmCrypto; 25 | 26 | try { 27 | KgmCryptoObj = await KgmCryptoModule(); 28 | } catch (err: any) { 29 | result.error = err?.message || 'wasm 加载失败'; 30 | return result; 31 | } 32 | if (!KgmCryptoObj) { 33 | result.error = 'wasm 加载失败'; 34 | return result; 35 | } 36 | 37 | // 申请内存块,并文件末端数据到 WASM 的内存堆 38 | let kgmBuf = new Uint8Array(kgmBlob); 39 | const pKgmBuf = KgmCryptoObj._malloc(DECRYPTION_BUF_SIZE); 40 | const preDecDataSize = Math.min(DECRYPTION_BUF_SIZE, kgmBlob.byteLength); // 初始化缓冲区大小 41 | KgmCryptoObj.writeArrayToMemory(kgmBuf.slice(0, preDecDataSize), pKgmBuf); 42 | 43 | // 进行解密初始化 44 | const headerSize = KgmCryptoObj.preDec(pKgmBuf, preDecDataSize, ext); 45 | kgmBuf = kgmBuf.slice(headerSize); 46 | 47 | const decryptedParts = []; 48 | let offset = 0; 49 | let bytesToDecrypt = kgmBuf.length; 50 | while (bytesToDecrypt > 0) { 51 | const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); 52 | 53 | // 解密一些片段 54 | const blockData = new Uint8Array(kgmBuf.slice(offset, offset + blockSize)); 55 | KgmCryptoObj.writeArrayToMemory(blockData, pKgmBuf); 56 | KgmCryptoObj.decBlob(pKgmBuf, blockSize, offset); 57 | decryptedParts.push(KgmCryptoObj.HEAPU8.slice(pKgmBuf, pKgmBuf + blockSize)); 58 | 59 | offset += blockSize; 60 | bytesToDecrypt -= blockSize; 61 | } 62 | KgmCryptoObj._free(pKgmBuf); 63 | 64 | result.data = MergeUint8Array(decryptedParts); 65 | result.success = true; 66 | 67 | return result; 68 | } 69 | -------------------------------------------------------------------------------- /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/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 (typeof this.oriMeta.artist === 'string') { 164 | // v3.0: artist 现在可能是字符串了? 165 | artists.push(this.oriMeta.artist); 166 | } else if (Array.isArray(this.oriMeta.artist)) { 167 | this.oriMeta.artist.forEach((artist) => { 168 | if (typeof artist === 'string') { 169 | artists.push(artist); 170 | } else if (Array.isArray(artist) && artist[0] && typeof artist[0] === 'string') { 171 | artists.push(artist[0]); 172 | } 173 | }); 174 | } 175 | 176 | if (artists.length === 0 && info.artist) { 177 | artists = info.artist 178 | .split(',') 179 | .map((val) => val.trim()) 180 | .filter((val) => val != ''); 181 | } 182 | 183 | if (this.oriMeta.albumPic) 184 | try { 185 | this.image = await GetImageFromURL(this.oriMeta.albumPic); 186 | while (this.image && this.image.buffer.byteLength >= 1 << 24) { 187 | let img = await jimp.read(Buffer.from(this.image.buffer)); 188 | await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO); 189 | this.image.buffer = await img.getBufferAsync('image/jpeg'); 190 | } 191 | } catch (e) { 192 | console.log('fetch cover image failed', e); 193 | } 194 | 195 | this.newMeta = { title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer }; 196 | } 197 | 198 | async _writeMeta() { 199 | if (!this.audio || !this.newMeta) throw Error('invalid sequence'); 200 | 201 | if (!this.blob) this.blob = new Blob([this.audio], { type: this.mime }); 202 | const ori = await metaParseBlob(this.blob); 203 | 204 | let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title; 205 | if (shouldWrite || this.newMeta.picture) { 206 | if (this.format === 'mp3') { 207 | this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori); 208 | } else if (this.format === 'flac') { 209 | this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori); 210 | } else { 211 | console.info(`writing meta for ${this.format} is not being supported for now`); 212 | return; 213 | } 214 | this.blob = new Blob([this.audio], { type: this.mime }); 215 | } 216 | } 217 | 218 | gatherResult(): DecryptResult { 219 | if (!this.newMeta || !this.blob) throw Error('bad sequence'); 220 | return { 221 | title: this.newMeta.title, 222 | artist: this.newMeta.artists?.join('; '), 223 | ext: this.format, 224 | album: this.newMeta.album, 225 | picture: this.image?.url, 226 | file: URL.createObjectURL(this.blob), 227 | blob: this.blob, 228 | mime: this.mime, 229 | }; 230 | } 231 | 232 | async decrypt() { 233 | const keyBox = this._getKeyBox(); 234 | this.oriMeta = this._getMetaData(); 235 | this.audio = this._getAudio(keyBox); 236 | this.format = this.oriMeta.format || SniffAudioExt(this.audio); 237 | this.mime = AudioMimeType[this.format]; 238 | 239 | try { 240 | await this._buildMeta(); 241 | await this._writeMeta(); 242 | } catch (e) { 243 | console.warn('build/write meta failed, skip.', e); 244 | } 245 | 246 | return this.gatherResult(); 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/decrypt/qmc.ts: -------------------------------------------------------------------------------- 1 | import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils'; 2 | 3 | import { DecryptResult } from '@/decrypt/entity'; 4 | import { DecryptQmcWasm } from '@/decrypt/qmc_wasm'; 5 | import { extractQQMusicMeta } from '@/utils/qm_meta'; 6 | 7 | interface Handler { 8 | ext: string; 9 | version: number; 10 | } 11 | 12 | export const HandlerMap: { [key: string]: Handler } = { 13 | mgg: { ext: 'ogg', version: 2 }, 14 | mgg0: { ext: 'ogg', version: 2 }, 15 | mggl: { ext: 'ogg', version: 2 }, 16 | mgg1: { ext: 'ogg', version: 2 }, 17 | mflac: { ext: 'flac', version: 2 }, 18 | mflac0: { ext: 'flac', version: 2 }, 19 | mmp4: { ext: 'mp4', version: 2 }, 20 | 21 | // qmcflac / qmcogg: 22 | // 有可能是 v2 加密但混用同一个后缀名。 23 | qmcflac: { ext: 'flac', version: 2 }, 24 | qmcogg: { ext: 'ogg', version: 2 }, 25 | 26 | qmc0: { ext: 'mp3', version: 2 }, 27 | qmc2: { ext: 'ogg', version: 2 }, 28 | qmc3: { ext: 'mp3', version: 2 }, 29 | qmc4: { ext: 'ogg', version: 2 }, 30 | qmc6: { ext: 'ogg', version: 2 }, 31 | qmc8: { ext: 'ogg', version: 2 }, 32 | bkcmp3: { ext: 'mp3', version: 1 }, 33 | bkcm4a: { ext: 'm4a', version: 1 }, 34 | bkcflac: { ext: 'flac', version: 1 }, 35 | bkcwav: { ext: 'wav', version: 1 }, 36 | bkcape: { ext: 'ape', version: 1 }, 37 | bkcogg: { ext: 'ogg', version: 1 }, 38 | bkcwma: { ext: 'wma', version: 1 }, 39 | tkm: { ext: 'm4a', version: 1 }, 40 | '666c6163': { ext: 'flac', version: 1 }, 41 | '6d7033': { ext: 'mp3', version: 1 }, 42 | '6f6767': { ext: 'ogg', version: 1 }, 43 | '6d3461': { ext: 'm4a', version: 1 }, 44 | '776176': { ext: 'wav', version: 1 }, 45 | }; 46 | 47 | export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { 48 | if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`; 49 | const handler = HandlerMap[raw_ext]; 50 | let { version } = handler; 51 | 52 | const fileBuffer = await GetArrayBuffer(file); 53 | let musicDecoded = new Uint8Array(); 54 | let musicID: number | string | undefined; 55 | 56 | if (version === 2 && globalThis.WebAssembly) { 57 | const v2Decrypted = await DecryptQmcWasm(fileBuffer, raw_ext); 58 | // 若 v2 检测失败,降级到 v1 再尝试一次 59 | if (v2Decrypted.success) { 60 | musicDecoded = v2Decrypted.data; 61 | musicID = v2Decrypted.songId; 62 | console.log('qmc wasm decoder suceeded'); 63 | } else { 64 | throw new Error(v2Decrypted.error || '(unknown error)'); 65 | } 66 | } 67 | 68 | const ext = SniffAudioExt(musicDecoded, handler.ext); 69 | const mime = AudioMimeType[ext]; 70 | 71 | const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( 72 | new Blob([musicDecoded], { type: mime }), 73 | raw_filename, 74 | ext, 75 | musicID, 76 | ); 77 | 78 | return { 79 | title: title, 80 | artist: artist, 81 | ext: ext, 82 | album: album, 83 | picture: imgUrl, 84 | file: URL.createObjectURL(blob), 85 | blob: blob, 86 | mime: mime, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/decrypt/qmc_wasm.ts: -------------------------------------------------------------------------------- 1 | import { QmcCrypto } from '@xhacker/qmcwasm/QmcWasmBundle'; 2 | import QmcCryptoModule from '@xhacker/qmcwasm/QmcWasmBundle'; 3 | import { MergeUint8Array } from '@/utils/MergeUint8Array'; 4 | 5 | // 每次可以处理 2M 的数据 6 | const DECRYPTION_BUF_SIZE = 2 *1024 * 1024; 7 | 8 | export interface QMCDecryptionResult { 9 | success: boolean; 10 | data: Uint8Array; 11 | songId: string | number; 12 | error: string; 13 | } 14 | 15 | /** 16 | * 解密一个 QMC 加密的文件。 17 | * 18 | * 如果检测并解密成功,返回解密后的 Uint8Array 数据。 19 | * @param {ArrayBuffer} qmcBlob 读入的文件 Blob 20 | */ 21 | export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise { 22 | const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' }; 23 | 24 | // 初始化模组 25 | let QmcCryptoObj: QmcCrypto; 26 | 27 | try { 28 | QmcCryptoObj = await QmcCryptoModule(); 29 | } catch (err: any) { 30 | result.error = err?.message || 'wasm 加载失败'; 31 | return result; 32 | } 33 | if (!QmcCryptoObj) { 34 | result.error = 'wasm 加载失败'; 35 | return result; 36 | } 37 | 38 | // 申请内存块,并文件末端数据到 WASM 的内存堆 39 | const qmcBuf = new Uint8Array(qmcBlob); 40 | const pQmcBuf = QmcCryptoObj._malloc(DECRYPTION_BUF_SIZE); 41 | const preDecDataSize = Math.min(DECRYPTION_BUF_SIZE, qmcBlob.byteLength); // 初始化缓冲区大小 42 | QmcCryptoObj.writeArrayToMemory(qmcBuf.slice(-preDecDataSize), pQmcBuf); 43 | 44 | // 进行解密初始化 45 | ext = '.' + ext; 46 | const tailSize = QmcCryptoObj.preDec(pQmcBuf, preDecDataSize, ext); 47 | if (tailSize == -1) { 48 | result.error = QmcCryptoObj.getErr(); 49 | return result; 50 | } else { 51 | result.songId = QmcCryptoObj.getSongId(); 52 | result.songId = result.songId == "0" ? 0 : result.songId; 53 | } 54 | 55 | const decryptedParts = []; 56 | let offset = 0; 57 | let bytesToDecrypt = qmcBuf.length - tailSize; 58 | while (bytesToDecrypt > 0) { 59 | const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); 60 | 61 | // 解密一些片段 62 | const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize)); 63 | QmcCryptoObj.writeArrayToMemory(blockData, pQmcBuf); 64 | decryptedParts.push(QmcCryptoObj.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCryptoObj.decBlob(pQmcBuf, blockSize, offset))); 65 | 66 | offset += blockSize; 67 | bytesToDecrypt -= blockSize; 68 | } 69 | QmcCryptoObj._free(pQmcBuf); 70 | 71 | result.data = MergeUint8Array(decryptedParts); 72 | result.success = true; 73 | 74 | return result; 75 | } 76 | -------------------------------------------------------------------------------- /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 = new Uint8Array(); 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 | throw new Error(qmcDecrypted.error || '(unknown error)'); 31 | } 32 | } 33 | 34 | let ext = SniffAudioExt(musicDecoded, ''); 35 | const newName = SplitFilename(raw_filename); 36 | let audioBlob: Blob; 37 | if (ext !== '' || newName.ext === 'mp3') { 38 | audioBlob = new Blob([musicDecoded], { type: AudioMimeType[ext] }); 39 | } else if (newName.ext in HandlerMap) { 40 | audioBlob = new Blob([musicDecoded], { type: 'application/octet-stream' }); 41 | return QmcDecrypt(audioBlob, newName.name, newName.ext); 42 | } else { 43 | throw '不支持的QQ音乐缓存格式'; 44 | } 45 | const tag = await metaParseBlob(audioBlob); 46 | const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || "")); 47 | 48 | return { 49 | title, 50 | artist, 51 | ext, 52 | album: tag.common.album, 53 | picture: GetCoverFromFile(tag), 54 | file: URL.createObjectURL(audioBlob), 55 | blob: audioBlob, 56 | mime: AudioMimeType[ext], 57 | }; 58 | } 59 | -------------------------------------------------------------------------------- /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/tm.ts: -------------------------------------------------------------------------------- 1 | import { Decrypt as RawDecrypt } from './raw'; 2 | import { GetArrayBuffer } from '@/decrypt/utils'; 3 | import { DecryptResult } from '@/decrypt/entity'; 4 | 5 | const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]; 6 | 7 | export async function Decrypt(file: File, raw_filename: string): Promise { 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/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/decrypt/ximalaya.ts: -------------------------------------------------------------------------------- 1 | import { parseBlob as metaParseBlob } from 'music-metadata-browser'; 2 | import { AudioMimeType, SniffAudioExt, GetArrayBuffer, GetMetaFromFile } from './utils'; 3 | import { DecryptResult } from '@/decrypt/entity'; 4 | 5 | const HandlerMap: Map Uint8Array> = new Map([ 6 | ['x2m', ProcessX2M], 7 | ['x3m', ProcessX3M], 8 | ]); 9 | 10 | export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise { 11 | const buffer = new Uint8Array(await GetArrayBuffer(file)); 12 | const handler = HandlerMap.get(raw_ext); 13 | if (!handler) throw 'File type is incorrect!'; 14 | let musicDecoded: Uint8Array = handler(buffer); 15 | 16 | const ext = SniffAudioExt(musicDecoded, 'm4a'); 17 | const mime = AudioMimeType[ext]; 18 | 19 | let musicBlob = new Blob([musicDecoded], { type: mime }); 20 | 21 | const musicMeta = await metaParseBlob(musicBlob); 22 | 23 | const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); 24 | 25 | return { 26 | picture: '', 27 | title: info.title, 28 | artist: info.artist, 29 | ext: ext, 30 | album: musicMeta.common.album, 31 | blob: musicBlob, 32 | file: URL.createObjectURL(musicBlob), 33 | mime: mime, 34 | }; 35 | } 36 | 37 | function ProcessX2M(data: Uint8Array) { 38 | const x2mHeaderSize = 1024; 39 | const x2mKey = [0x78, 0x6d, 0x6c, 0x79]; 40 | let encryptedHeader = data.slice(0, x2mHeaderSize); 41 | for (let idx = 0; idx < x2mHeaderSize; idx++) { 42 | let srcIdx = x2mScrambleTable[idx]; 43 | data[idx] = encryptedHeader[srcIdx] ^ x2mKey[idx % x2mKey.length]; 44 | } 45 | return data; 46 | } 47 | 48 | function ProcessX3M(data: Uint8Array) { 49 | const x3mHeaderSize = 1024; 50 | 51 | //prettier-ignore: uint8, size 32(8x4) 52 | const x3mKey = [ 53 | 0x33, 0x39, 0x38, 0x39, 0x64, 0x31, 0x31, 0x31, 0x61, 0x61, 0x64, 0x35, 0x36, 0x31, 0x33, 0x39, 0x34, 0x30, 0x66, 54 | 0x34, 0x66, 0x63, 0x34, 0x34, 0x62, 0x36, 0x33, 0x39, 0x62, 0x32, 0x39, 0x32, 55 | ]; 56 | 57 | let encryptedHeader = data.slice(0, x3mHeaderSize); 58 | for (let dstIdx = 0; dstIdx < x3mHeaderSize; dstIdx++) { 59 | let srcIdx = x3mScrambleTable[dstIdx]; 60 | data[dstIdx] = encryptedHeader[srcIdx] ^ x3mKey[dstIdx % x3mKey.length]; 61 | } 62 | return data; 63 | } 64 | 65 | //prettier-ignore: uint16, size 1024 (64x16) 66 | const x2mScrambleTable = [ 67 | 0x2a9, 0x2ab, 0x154, 0x2aa, 0x2a8, 0x2ac, 0x153, 0x2a7, 0x2ad, 0x152, 0x2a6, 0x3ff, 0x000, 0x155, 0x2ae, 0x151, 0x2a5, 68 | 0x3fe, 0x001, 0x156, 0x2af, 0x150, 0x2a4, 0x3fd, 0x002, 0x157, 0x2b0, 0x14f, 0x2a3, 0x3fc, 0x003, 0x158, 0x2b1, 0x14e, 69 | 0x2a2, 0x3fb, 0x004, 0x159, 0x2b2, 0x14d, 0x2a1, 0x3fa, 0x005, 0x15a, 0x2b3, 0x14c, 0x2a0, 0x3f9, 0x006, 0x15b, 0x2b4, 70 | 0x14b, 0x29f, 0x3f8, 0x007, 0x15c, 0x2b5, 0x14a, 0x29e, 0x3f7, 0x008, 0x15d, 0x2b6, 0x149, 0x29d, 0x3f6, 0x009, 0x15e, 71 | 0x2b7, 0x148, 0x29c, 0x3f5, 0x00a, 0x15f, 0x2b8, 0x147, 0x29b, 0x3f4, 0x00b, 0x160, 0x2b9, 0x146, 0x29a, 0x3f3, 0x00c, 72 | 0x161, 0x2ba, 0x145, 0x299, 0x3f2, 0x00d, 0x162, 0x2bb, 0x144, 0x298, 0x3f1, 0x00e, 0x163, 0x2bc, 0x143, 0x297, 0x3f0, 73 | 0x00f, 0x164, 0x2bd, 0x142, 0x296, 0x3ef, 0x010, 0x165, 0x2be, 0x141, 0x295, 0x3ee, 0x011, 0x166, 0x2bf, 0x140, 0x294, 74 | 0x3ed, 0x012, 0x167, 0x2c0, 0x13f, 0x293, 0x3ec, 0x013, 0x168, 0x2c1, 0x13e, 0x292, 0x3eb, 0x014, 0x169, 0x2c2, 0x13d, 75 | 0x291, 0x3ea, 0x015, 0x16a, 0x2c3, 0x13c, 0x290, 0x3e9, 0x016, 0x16b, 0x2c4, 0x13b, 0x28f, 0x3e8, 0x017, 0x16c, 0x2c5, 76 | 0x13a, 0x28e, 0x3e7, 0x018, 0x16d, 0x2c6, 0x139, 0x28d, 0x3e6, 0x019, 0x16e, 0x2c7, 0x138, 0x28c, 0x3e5, 0x01a, 0x16f, 77 | 0x2c8, 0x137, 0x28b, 0x3e4, 0x01b, 0x170, 0x2c9, 0x136, 0x28a, 0x3e3, 0x01c, 0x171, 0x2ca, 0x135, 0x289, 0x3e2, 0x01d, 78 | 0x172, 0x2cb, 0x134, 0x288, 0x3e1, 0x01e, 0x173, 0x2cc, 0x133, 0x287, 0x3e0, 0x01f, 0x174, 0x2cd, 0x0a9, 0x1fe, 0x357, 79 | 0x020, 0x175, 0x2ce, 0x0aa, 0x1ff, 0x358, 0x021, 0x176, 0x2cf, 0x0ab, 0x200, 0x359, 0x022, 0x177, 0x2d0, 0x0ac, 0x201, 80 | 0x35a, 0x023, 0x178, 0x2d1, 0x0ad, 0x202, 0x35b, 0x024, 0x179, 0x2d2, 0x0ae, 0x203, 0x35c, 0x025, 0x17a, 0x2d3, 0x0af, 81 | 0x204, 0x35d, 0x026, 0x17b, 0x2d4, 0x0b0, 0x205, 0x35e, 0x027, 0x17c, 0x2d5, 0x0b1, 0x206, 0x35f, 0x028, 0x17d, 0x2d6, 82 | 0x0b2, 0x207, 0x360, 0x029, 0x17e, 0x2d7, 0x0b3, 0x208, 0x361, 0x02a, 0x17f, 0x2d8, 0x0b4, 0x209, 0x362, 0x02b, 0x180, 83 | 0x2d9, 0x0b5, 0x20a, 0x363, 0x02c, 0x181, 0x2da, 0x0b6, 0x20b, 0x364, 0x02d, 0x182, 0x2db, 0x0b7, 0x20c, 0x365, 0x02e, 84 | 0x183, 0x2dc, 0x0b8, 0x20d, 0x366, 0x02f, 0x184, 0x2dd, 0x0b9, 0x20e, 0x367, 0x030, 0x185, 0x2de, 0x0ba, 0x20f, 0x368, 85 | 0x031, 0x186, 0x2df, 0x0bb, 0x210, 0x369, 0x032, 0x187, 0x2e0, 0x0bc, 0x211, 0x36a, 0x033, 0x188, 0x2e1, 0x0bd, 0x212, 86 | 0x36b, 0x034, 0x189, 0x2e2, 0x0be, 0x213, 0x36c, 0x035, 0x18a, 0x2e3, 0x0bf, 0x214, 0x36d, 0x036, 0x18b, 0x2e4, 0x0c0, 87 | 0x215, 0x36e, 0x037, 0x18c, 0x2e5, 0x0c1, 0x216, 0x36f, 0x038, 0x18d, 0x2e6, 0x0c2, 0x217, 0x370, 0x039, 0x18e, 0x2e7, 88 | 0x0c3, 0x218, 0x371, 0x03a, 0x18f, 0x2e8, 0x0c4, 0x219, 0x372, 0x03b, 0x190, 0x2e9, 0x0c5, 0x21a, 0x373, 0x03c, 0x191, 89 | 0x2ea, 0x0c6, 0x21b, 0x374, 0x03d, 0x192, 0x2eb, 0x0c7, 0x21c, 0x375, 0x03e, 0x193, 0x2ec, 0x0c8, 0x21d, 0x376, 0x03f, 90 | 0x194, 0x2ed, 0x0c9, 0x21e, 0x377, 0x040, 0x195, 0x2ee, 0x0ca, 0x21f, 0x378, 0x041, 0x196, 0x2ef, 0x0cb, 0x220, 0x379, 91 | 0x042, 0x197, 0x2f0, 0x0cc, 0x221, 0x37a, 0x043, 0x198, 0x2f1, 0x0cd, 0x222, 0x37b, 0x044, 0x199, 0x2f2, 0x0ce, 0x223, 92 | 0x37c, 0x045, 0x19a, 0x2f3, 0x0cf, 0x224, 0x37d, 0x046, 0x19b, 0x2f4, 0x0d0, 0x225, 0x37e, 0x047, 0x19c, 0x2f5, 0x0d1, 93 | 0x226, 0x37f, 0x048, 0x19d, 0x2f6, 0x0d2, 0x227, 0x380, 0x049, 0x19e, 0x2f7, 0x0d3, 0x228, 0x381, 0x04a, 0x19f, 0x2f8, 94 | 0x0d4, 0x229, 0x382, 0x04b, 0x1a0, 0x2f9, 0x0d5, 0x22a, 0x383, 0x04c, 0x1a1, 0x2fa, 0x0d6, 0x22b, 0x384, 0x04d, 0x1a2, 95 | 0x2fb, 0x0d7, 0x22c, 0x385, 0x04e, 0x1a3, 0x2fc, 0x0d8, 0x22d, 0x386, 0x04f, 0x1a4, 0x2fd, 0x0d9, 0x22e, 0x387, 0x050, 96 | 0x1a5, 0x2fe, 0x0da, 0x22f, 0x388, 0x051, 0x1a6, 0x2ff, 0x0db, 0x230, 0x389, 0x052, 0x1a7, 0x300, 0x0dc, 0x231, 0x38a, 97 | 0x053, 0x1a8, 0x301, 0x0dd, 0x232, 0x38b, 0x054, 0x1a9, 0x302, 0x0de, 0x233, 0x38c, 0x055, 0x1aa, 0x303, 0x0df, 0x234, 98 | 0x38d, 0x056, 0x1ab, 0x304, 0x0e0, 0x235, 0x38e, 0x057, 0x1ac, 0x305, 0x0e1, 0x236, 0x38f, 0x058, 0x1ad, 0x306, 0x0e2, 99 | 0x237, 0x390, 0x059, 0x1ae, 0x307, 0x0e3, 0x238, 0x391, 0x05a, 0x1af, 0x308, 0x0e4, 0x239, 0x392, 0x05b, 0x1b0, 0x309, 100 | 0x0e5, 0x23a, 0x393, 0x05c, 0x1b1, 0x30a, 0x0e6, 0x23b, 0x394, 0x05d, 0x1b2, 0x30b, 0x0e7, 0x23c, 0x395, 0x05e, 0x1b3, 101 | 0x30c, 0x0e8, 0x23d, 0x396, 0x05f, 0x1b4, 0x30d, 0x0e9, 0x23e, 0x397, 0x060, 0x1b5, 0x30e, 0x0ea, 0x23f, 0x398, 0x061, 102 | 0x1b6, 0x30f, 0x0eb, 0x240, 0x399, 0x062, 0x1b7, 0x310, 0x0ec, 0x241, 0x39a, 0x063, 0x1b8, 0x311, 0x0ed, 0x242, 0x39b, 103 | 0x064, 0x1b9, 0x312, 0x0ee, 0x243, 0x39c, 0x065, 0x1ba, 0x313, 0x0ef, 0x244, 0x39d, 0x066, 0x1bb, 0x314, 0x0f0, 0x245, 104 | 0x39e, 0x067, 0x1bc, 0x315, 0x0f1, 0x246, 0x39f, 0x068, 0x1bd, 0x316, 0x0f2, 0x247, 0x3a0, 0x069, 0x1be, 0x317, 0x0f3, 105 | 0x248, 0x3a1, 0x06a, 0x1bf, 0x318, 0x0f4, 0x249, 0x3a2, 0x06b, 0x1c0, 0x319, 0x0f5, 0x24a, 0x3a3, 0x06c, 0x1c1, 0x31a, 106 | 0x0f6, 0x24b, 0x3a4, 0x06d, 0x1c2, 0x31b, 0x0f7, 0x24c, 0x3a5, 0x06e, 0x1c3, 0x31c, 0x0f8, 0x24d, 0x3a6, 0x06f, 0x1c4, 107 | 0x31d, 0x0f9, 0x24e, 0x3a7, 0x070, 0x1c5, 0x31e, 0x0fa, 0x24f, 0x3a8, 0x071, 0x1c6, 0x31f, 0x0fb, 0x250, 0x3a9, 0x072, 108 | 0x1c7, 0x320, 0x0fc, 0x251, 0x3aa, 0x073, 0x1c8, 0x321, 0x0fd, 0x252, 0x3ab, 0x074, 0x1c9, 0x322, 0x0fe, 0x253, 0x3ac, 109 | 0x075, 0x1ca, 0x323, 0x0ff, 0x254, 0x3ad, 0x076, 0x1cb, 0x324, 0x100, 0x255, 0x3ae, 0x077, 0x1cc, 0x325, 0x101, 0x256, 110 | 0x3af, 0x078, 0x1cd, 0x326, 0x102, 0x257, 0x3b0, 0x079, 0x1ce, 0x327, 0x103, 0x258, 0x3b1, 0x07a, 0x1cf, 0x328, 0x104, 111 | 0x259, 0x3b2, 0x07b, 0x1d0, 0x329, 0x105, 0x25a, 0x3b3, 0x07c, 0x1d1, 0x32a, 0x106, 0x25b, 0x3b4, 0x07d, 0x1d2, 0x32b, 112 | 0x107, 0x25c, 0x3b5, 0x07e, 0x1d3, 0x32c, 0x108, 0x25d, 0x3b6, 0x07f, 0x1d4, 0x32d, 0x109, 0x25e, 0x3b7, 0x080, 0x1d5, 113 | 0x32e, 0x10a, 0x25f, 0x3b8, 0x081, 0x1d6, 0x32f, 0x10b, 0x260, 0x3b9, 0x082, 0x1d7, 0x330, 0x10c, 0x261, 0x3ba, 0x083, 114 | 0x1d8, 0x331, 0x10d, 0x262, 0x3bb, 0x084, 0x1d9, 0x332, 0x10e, 0x263, 0x3bc, 0x085, 0x1da, 0x333, 0x10f, 0x264, 0x3bd, 115 | 0x086, 0x1db, 0x334, 0x110, 0x265, 0x3be, 0x087, 0x1dc, 0x335, 0x111, 0x266, 0x3bf, 0x088, 0x1dd, 0x336, 0x112, 0x267, 116 | 0x3c0, 0x089, 0x1de, 0x337, 0x113, 0x268, 0x3c1, 0x08a, 0x1df, 0x338, 0x114, 0x269, 0x3c2, 0x08b, 0x1e0, 0x339, 0x115, 117 | 0x26a, 0x3c3, 0x08c, 0x1e1, 0x33a, 0x116, 0x26b, 0x3c4, 0x08d, 0x1e2, 0x33b, 0x117, 0x26c, 0x3c5, 0x08e, 0x1e3, 0x33c, 118 | 0x118, 0x26d, 0x3c6, 0x08f, 0x1e4, 0x33d, 0x119, 0x26e, 0x3c7, 0x090, 0x1e5, 0x33e, 0x11a, 0x26f, 0x3c8, 0x091, 0x1e6, 119 | 0x33f, 0x11b, 0x270, 0x3c9, 0x092, 0x1e7, 0x340, 0x11c, 0x271, 0x3ca, 0x093, 0x1e8, 0x341, 0x11d, 0x272, 0x3cb, 0x094, 120 | 0x1e9, 0x342, 0x11e, 0x273, 0x3cc, 0x095, 0x1ea, 0x343, 0x11f, 0x274, 0x3cd, 0x096, 0x1eb, 0x344, 0x120, 0x275, 0x3ce, 121 | 0x097, 0x1ec, 0x345, 0x121, 0x276, 0x3cf, 0x098, 0x1ed, 0x346, 0x122, 0x277, 0x3d0, 0x099, 0x1ee, 0x347, 0x123, 0x278, 122 | 0x3d1, 0x09a, 0x1ef, 0x348, 0x124, 0x279, 0x3d2, 0x09b, 0x1f0, 0x349, 0x125, 0x27a, 0x3d3, 0x09c, 0x1f1, 0x34a, 0x126, 123 | 0x27b, 0x3d4, 0x09d, 0x1f2, 0x34b, 0x127, 0x27c, 0x3d5, 0x09e, 0x1f3, 0x34c, 0x128, 0x27d, 0x3d6, 0x09f, 0x1f4, 0x34d, 124 | 0x129, 0x27e, 0x3d7, 0x0a0, 0x1f5, 0x34e, 0x12a, 0x27f, 0x3d8, 0x0a1, 0x1f6, 0x34f, 0x12b, 0x280, 0x3d9, 0x0a2, 0x1f7, 125 | 0x350, 0x12c, 0x281, 0x3da, 0x0a3, 0x1f8, 0x351, 0x12d, 0x282, 0x3db, 0x0a4, 0x1f9, 0x352, 0x12e, 0x283, 0x3dc, 0x0a5, 126 | 0x1fa, 0x353, 0x12f, 0x284, 0x3dd, 0x0a6, 0x1fb, 0x354, 0x130, 0x285, 0x3de, 0x0a7, 0x1fc, 0x355, 0x131, 0x286, 0x3df, 127 | 0x0a8, 0x1fd, 0x356, 0x132, 128 | ]; 129 | 130 | //prettier-ignore: uint16, size 1024 (64x16) 131 | const x3mScrambleTable = [ 132 | 0x256, 0x28d, 0x213, 0x307, 0x156, 0x39d, 0x062, 0x170, 0x3ca, 0x035, 0x0ed, 0x2a4, 0x1e4, 0x359, 0x0d3, 0x26b, 0x265, 133 | 0x274, 0x251, 0x297, 0x202, 0x322, 0x126, 0x32b, 0x117, 0x302, 0x15c, 0x3a8, 0x057, 0x148, 0x380, 0x090, 0x1f6, 0x335, 134 | 0x10c, 0x2ee, 0x175, 0x3d4, 0x02b, 0x0cc, 0x260, 0x27b, 0x23d, 0x2bb, 0x1b6, 0x3a1, 0x05e, 0x157, 0x39e, 0x061, 0x16f, 135 | 0x3c6, 0x039, 0x0f7, 0x2b9, 0x1b8, 0x39f, 0x060, 0x166, 0x3b9, 0x046, 0x122, 0x31c, 0x12f, 0x33d, 0x0fc, 0x2ca, 0x1a4, 136 | 0x3cc, 0x033, 0x0e6, 0x293, 0x209, 0x315, 0x13d, 0x358, 0x0d5, 0x26e, 0x25e, 0x27d, 0x23a, 0x2c0, 0x1b1, 0x3af, 0x050, 137 | 0x136, 0x346, 0x0ef, 0x2aa, 0x1ce, 0x376, 0x0a0, 0x210, 0x30c, 0x14c, 0x389, 0x082, 0x1db, 0x367, 0x0b9, 0x23e, 0x2ba, 138 | 0x1b7, 0x3a0, 0x05f, 0x164, 0x3b7, 0x048, 0x125, 0x326, 0x11c, 0x30a, 0x14f, 0x38f, 0x070, 0x1a8, 0x3c7, 0x038, 0x0f5, 139 | 0x2b5, 0x1bd, 0x393, 0x06c, 0x199, 0x3e1, 0x01e, 0x0b3, 0x22f, 0x2d7, 0x193, 0x3ea, 0x015, 0x09d, 0x20a, 0x314, 0x13e, 140 | 0x35a, 0x0d2, 0x26a, 0x267, 0x272, 0x253, 0x294, 0x208, 0x319, 0x137, 0x34c, 0x0e7, 0x295, 0x205, 0x31d, 0x12e, 0x33c, 141 | 0x0fe, 0x2cd, 0x1a0, 0x3d5, 0x02a, 0x0c8, 0x258, 0x286, 0x22a, 0x2dc, 0x18e, 0x3f7, 0x008, 0x07c, 0x1d3, 0x370, 0x0a7, 142 | 0x21d, 0x2f1, 0x171, 0x3cd, 0x032, 0x0e5, 0x292, 0x20b, 0x313, 0x13f, 0x35c, 0x0d0, 0x266, 0x273, 0x252, 0x296, 0x204, 143 | 0x31f, 0x12a, 0x332, 0x10f, 0x2f4, 0x16c, 0x3c3, 0x03c, 0x101, 0x2d2, 0x19a, 0x3e0, 0x01f, 0x0b5, 0x233, 0x2c9, 0x1a6, 144 | 0x3c9, 0x036, 0x0f0, 0x2ab, 0x1cb, 0x37c, 0x095, 0x1fd, 0x328, 0x11a, 0x306, 0x158, 0x3a2, 0x05d, 0x155, 0x39c, 0x063, 145 | 0x174, 0x3d3, 0x02c, 0x0cf, 0x264, 0x275, 0x24f, 0x299, 0x1fa, 0x32c, 0x115, 0x2ff, 0x15f, 0x3ab, 0x054, 0x143, 0x36c, 146 | 0x0ad, 0x225, 0x2e5, 0x181, 0x3ef, 0x010, 0x08c, 0x1f1, 0x344, 0x0f3, 0x2af, 0x1c4, 0x386, 0x088, 0x1e3, 0x35b, 0x0d1, 147 | 0x269, 0x268, 0x26d, 0x25f, 0x27c, 0x23b, 0x2bf, 0x1b2, 0x3ae, 0x051, 0x13b, 0x355, 0x0da, 0x278, 0x248, 0x2a6, 0x1dc, 148 | 0x365, 0x0c0, 0x246, 0x2a8, 0x1d6, 0x36d, 0x0ac, 0x224, 0x2e8, 0x17e, 0x3eb, 0x014, 0x09c, 0x207, 0x31a, 0x133, 0x341, 149 | 0x0f8, 0x2bc, 0x1b5, 0x3a3, 0x05c, 0x152, 0x395, 0x06a, 0x18c, 0x3f9, 0x006, 0x07a, 0x1d1, 0x373, 0x0a4, 0x217, 0x2fe, 150 | 0x160, 0x3ad, 0x052, 0x13c, 0x357, 0x0d7, 0x270, 0x25c, 0x281, 0x235, 0x2c6, 0x1aa, 0x3bc, 0x043, 0x11d, 0x30d, 0x14a, 151 | 0x384, 0x08a, 0x1e7, 0x353, 0x0dd, 0x284, 0x22e, 0x2d8, 0x192, 0x3ec, 0x013, 0x099, 0x201, 0x323, 0x124, 0x321, 0x127, 152 | 0x32d, 0x114, 0x2fd, 0x161, 0x3b0, 0x04f, 0x135, 0x343, 0x0f4, 0x2b4, 0x1be, 0x392, 0x06d, 0x19d, 0x3db, 0x024, 0x0be, 153 | 0x244, 0x2b0, 0x1c2, 0x38a, 0x080, 0x1d9, 0x369, 0x0b6, 0x234, 0x2c8, 0x1a7, 0x3c8, 0x037, 0x0f1, 0x2ad, 0x1c6, 0x383, 154 | 0x08d, 0x1f2, 0x33b, 0x100, 0x2d1, 0x19b, 0x3de, 0x021, 0x0bb, 0x240, 0x2b6, 0x1bb, 0x399, 0x066, 0x17a, 0x3df, 0x020, 155 | 0x0b8, 0x23c, 0x2bd, 0x1b4, 0x3a5, 0x05a, 0x150, 0x390, 0x06f, 0x1a5, 0x3cb, 0x034, 0x0ea, 0x29d, 0x1ee, 0x348, 0x0ec, 156 | 0x2a3, 0x1e5, 0x356, 0x0d8, 0x271, 0x257, 0x289, 0x220, 0x2ec, 0x178, 0x3d9, 0x026, 0x0c2, 0x24b, 0x2a1, 0x1ea, 0x34d, 157 | 0x0e4, 0x291, 0x20c, 0x312, 0x141, 0x360, 0x0ca, 0x25a, 0x283, 0x230, 0x2d0, 0x19c, 0x3dd, 0x022, 0x0bc, 0x241, 0x2b3, 158 | 0x1bf, 0x391, 0x06e, 0x1a2, 0x3d1, 0x02e, 0x0d6, 0x26f, 0x25d, 0x27f, 0x237, 0x2c4, 0x1ac, 0x3ba, 0x045, 0x121, 0x318, 159 | 0x138, 0x34e, 0x0e3, 0x28f, 0x211, 0x30b, 0x14d, 0x38c, 0x073, 0x1c3, 0x387, 0x084, 0x1df, 0x362, 0x0c7, 0x255, 0x28e, 160 | 0x212, 0x309, 0x153, 0x396, 0x069, 0x18b, 0x3fa, 0x005, 0x079, 0x1d0, 0x374, 0x0a2, 0x215, 0x301, 0x15d, 0x3a9, 0x056, 161 | 0x147, 0x37a, 0x098, 0x200, 0x324, 0x11f, 0x316, 0x13a, 0x352, 0x0df, 0x288, 0x223, 0x2e9, 0x17d, 0x3e9, 0x016, 0x09e, 162 | 0x20d, 0x310, 0x144, 0x372, 0x0a5, 0x219, 0x2fa, 0x165, 0x3b8, 0x047, 0x123, 0x31e, 0x12d, 0x338, 0x107, 0x2e0, 0x188, 163 | 0x3fe, 0x001, 0x075, 0x1c9, 0x37e, 0x093, 0x1f9, 0x32f, 0x112, 0x2f9, 0x167, 0x3be, 0x041, 0x10b, 0x2e7, 0x17f, 0x3ed, 164 | 0x012, 0x097, 0x1ff, 0x325, 0x11e, 0x311, 0x142, 0x366, 0x0ba, 0x23f, 0x2b8, 0x1b9, 0x39b, 0x064, 0x176, 0x3d6, 0x029, 165 | 0x0c5, 0x250, 0x298, 0x1fc, 0x329, 0x119, 0x304, 0x15a, 0x3a6, 0x059, 0x14e, 0x38e, 0x071, 0x1ad, 0x3b6, 0x049, 0x128, 166 | 0x32e, 0x113, 0x2fc, 0x162, 0x3b2, 0x04d, 0x131, 0x33f, 0x0fa, 0x2c2, 0x1af, 0x3b3, 0x04c, 0x130, 0x33e, 0x0fb, 0x2c7, 167 | 0x1a9, 0x3bd, 0x042, 0x116, 0x300, 0x15e, 0x3aa, 0x055, 0x146, 0x378, 0x09b, 0x206, 0x31b, 0x132, 0x340, 0x0f9, 0x2be, 168 | 0x1b3, 0x3ac, 0x053, 0x140, 0x35d, 0x0ce, 0x262, 0x279, 0x247, 0x2a7, 0x1d7, 0x36b, 0x0ae, 0x226, 0x2e3, 0x185, 0x3f6, 169 | 0x009, 0x07d, 0x1d4, 0x36f, 0x0a8, 0x21e, 0x2f0, 0x172, 0x3ce, 0x031, 0x0de, 0x287, 0x228, 0x2df, 0x189, 0x3fd, 0x002, 170 | 0x076, 0x1ca, 0x37d, 0x094, 0x1fb, 0x32a, 0x118, 0x303, 0x15b, 0x3a7, 0x058, 0x14b, 0x388, 0x083, 0x1dd, 0x364, 0x0c1, 171 | 0x24a, 0x2a2, 0x1e9, 0x350, 0x0e1, 0x28b, 0x21a, 0x2f8, 0x168, 0x3bf, 0x040, 0x10a, 0x2e6, 0x180, 0x3ee, 0x011, 0x091, 172 | 0x1f7, 0x334, 0x10d, 0x2ef, 0x173, 0x3cf, 0x030, 0x0dc, 0x280, 0x236, 0x2c5, 0x1ab, 0x3bb, 0x044, 0x120, 0x317, 0x139, 173 | 0x34f, 0x0e2, 0x28c, 0x218, 0x2fb, 0x163, 0x3b4, 0x04b, 0x12c, 0x337, 0x108, 0x2e2, 0x186, 0x3fc, 0x003, 0x077, 0x1cc, 174 | 0x37b, 0x096, 0x1fe, 0x327, 0x11b, 0x308, 0x154, 0x397, 0x068, 0x183, 0x3f3, 0x00c, 0x085, 0x1e0, 0x361, 0x0c9, 0x259, 175 | 0x285, 0x22c, 0x2da, 0x190, 0x3f2, 0x00d, 0x086, 0x1e1, 0x35f, 0x0cb, 0x25b, 0x282, 0x232, 0x2cc, 0x1a1, 0x3d2, 0x02d, 176 | 0x0d4, 0x26c, 0x263, 0x277, 0x249, 0x2a5, 0x1de, 0x363, 0x0c6, 0x254, 0x290, 0x20e, 0x30f, 0x145, 0x377, 0x09f, 0x20f, 177 | 0x30e, 0x149, 0x382, 0x08e, 0x1f3, 0x33a, 0x102, 0x2d3, 0x198, 0x3e2, 0x01d, 0x0b2, 0x22d, 0x2d9, 0x191, 0x3f1, 0x00e, 178 | 0x087, 0x1e2, 0x35e, 0x0cd, 0x261, 0x27a, 0x243, 0x2b1, 0x1c1, 0x38b, 0x07f, 0x1d8, 0x36a, 0x0b4, 0x231, 0x2cf, 0x19e, 179 | 0x3da, 0x025, 0x0bf, 0x245, 0x2ac, 0x1c7, 0x381, 0x08f, 0x1f5, 0x336, 0x109, 0x2e4, 0x184, 0x3f4, 0x00b, 0x081, 0x1da, 180 | 0x368, 0x0b7, 0x239, 0x2c1, 0x1b0, 0x3b1, 0x04e, 0x134, 0x342, 0x0f6, 0x2b7, 0x1ba, 0x39a, 0x065, 0x179, 0x3dc, 0x023, 181 | 0x0bd, 0x242, 0x2b2, 0x1c0, 0x38d, 0x072, 0x1bc, 0x398, 0x067, 0x182, 0x3f0, 0x00f, 0x08b, 0x1e8, 0x351, 0x0e0, 0x28a, 182 | 0x21c, 0x2f3, 0x16d, 0x3c4, 0x03b, 0x0ff, 0x2ce, 0x19f, 0x3d7, 0x028, 0x0c4, 0x24e, 0x29b, 0x1f0, 0x345, 0x0f2, 0x2ae, 183 | 0x1c5, 0x385, 0x089, 0x1e6, 0x354, 0x0db, 0x27e, 0x238, 0x2c3, 0x1ae, 0x3b5, 0x04a, 0x12b, 0x333, 0x10e, 0x2f2, 0x16e, 184 | 0x3c5, 0x03a, 0x0fd, 0x2cb, 0x1a3, 0x3d0, 0x02f, 0x0d9, 0x276, 0x24c, 0x29f, 0x1ec, 0x34a, 0x0e9, 0x29c, 0x1ef, 0x347, 185 | 0x0ee, 0x2a9, 0x1cf, 0x375, 0x0a1, 0x214, 0x305, 0x159, 0x3a4, 0x05b, 0x151, 0x394, 0x06b, 0x196, 0x3e6, 0x019, 0x0ab, 186 | 0x222, 0x2ea, 0x17c, 0x3e5, 0x01a, 0x0af, 0x227, 0x2e1, 0x187, 0x3ff, 0x000, 0x074, 0x1c8, 0x37f, 0x092, 0x1f8, 0x331, 187 | 0x110, 0x2f6, 0x16a, 0x3c1, 0x03e, 0x104, 0x2d5, 0x195, 0x3e7, 0x018, 0x0aa, 0x221, 0x2eb, 0x17b, 0x3e4, 0x01b, 0x0b0, 188 | 0x229, 0x2dd, 0x18d, 0x3f8, 0x007, 0x07b, 0x1d2, 0x371, 0x0a6, 0x21b, 0x2f5, 0x16b, 0x3c2, 0x03d, 0x103, 0x2d4, 0x197, 189 | 0x3e3, 0x01c, 0x0b1, 0x22b, 0x2db, 0x18f, 0x3f5, 0x00a, 0x07e, 0x1d5, 0x36e, 0x0a9, 0x21f, 0x2ed, 0x177, 0x3d8, 0x027, 190 | 0x0c3, 0x24d, 0x29e, 0x1ed, 0x349, 0x0eb, 0x2a0, 0x1eb, 0x34b, 0x0e8, 0x29a, 0x1f4, 0x339, 0x106, 0x2de, 0x18a, 0x3fb, 191 | 0x004, 0x078, 0x1cd, 0x379, 0x09a, 0x203, 0x320, 0x129, 0x330, 0x111, 0x2f7, 0x169, 0x3c0, 0x03f, 0x105, 0x2d6, 0x194, 192 | 0x3e8, 0x017, 0x0a3, 0x216, 193 | ]; 194 | -------------------------------------------------------------------------------- /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/extension/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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 | // 编辑歌曲信息 14 | .music-cover{ 15 | i{ 16 | &:hover{ 17 | color: $color-checkbox; 18 | } 19 | } 20 | .el-image{ 21 | border: 1px solid $dark-border; 22 | } 23 | } 24 | .edit-item{ 25 | .label{ 26 | } 27 | .value{ 28 | } 29 | .input{ 30 | input{ 31 | background-color: transparent !important; 32 | border-bottom: 1px solid $dark-border; 33 | } 34 | } 35 | i{ 36 | &:hover{ 37 | color: $color-checkbox; 38 | } 39 | } 40 | } 41 | 42 | 43 | // footer 44 | #app-footer { 45 | a { 46 | color: lighten($text-comment, 5%); 47 | &:hover{ 48 | color: $color-link; 49 | } 50 | } 51 | } 52 | 53 | // 自定义样式 54 | // 首页弹窗提示信息的 更新信息 面板 55 | .update-info{ 56 | border: 1px solid $dark-btn-bg !important; 57 | .update-title{ 58 | color: $dark-text-main; 59 | background-color: $dark-btn-bg !important; 60 | } 61 | .update-content{ 62 | color: $dark-text-info; 63 | padding: 10px; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/scss/_element-ui-overwrite.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 | 41 | // upload 42 | .el-upload-dragger{ 43 | &:hover{ 44 | background-color: transparentize($color-checkbox, 0.9); 45 | } 46 | } 47 | .el-upload__tip{ 48 | text-align: center; 49 | color: $text-comment; 50 | } 51 | 52 | 53 | // dialog 54 | .el-dialog{ 55 | @include border-radius(5px); 56 | &.el-dialog--center{ 57 | .el-dialog__body{ 58 | padding: 25px 25px 15px; 59 | } 60 | .el-dialog__footer{ 61 | padding: 10px 20px 30px; 62 | } 63 | } 64 | } 65 | 66 | 67 | 68 | @media (prefers-color-scheme: dark) { 69 | 70 | // FORM 71 | .el-radio{ 72 | &__label{ 73 | color: $dark-text-main; 74 | } 75 | &__input{ 76 | color: $dark-text-info; 77 | .el-radio__inner{ 78 | border-color: $dark-border; 79 | background-color: $dark-btn-bg; 80 | } 81 | } 82 | 83 | &.is-checked{ 84 | .el-radio__inner{ 85 | background-color: $blue; 86 | } 87 | .el-radio__label{ 88 | font-weight: bold; 89 | } 90 | } 91 | } 92 | 93 | .el-checkbox.is-bordered{ 94 | border-color: $dark-border; 95 | color: $dark-text-main; 96 | background-color: $dark-btn-bg; 97 | .el-checkbox__inner{ 98 | background-color: $dark-btn-bg-highlight; 99 | border-color: $dark-border-highlight; 100 | } 101 | &:hover{ 102 | border-color: $dark-border-highlight; 103 | .el-checkbox__inner{ 104 | background-color: $dark-btn-bg-highlight; 105 | border-color: $dark-border-highlight; 106 | } 107 | .el-checkbox__label{ 108 | color: $dark-text-info; 109 | } 110 | } 111 | &.is-checked{ 112 | background-color: $blue; 113 | .el-checkbox__inner{ 114 | border-color: white; 115 | } 116 | .el-checkbox__label{ 117 | color: white; 118 | font-weight: bold; 119 | } 120 | &:hover{ 121 | border-color: $blue; 122 | .el-checkbox__inner{ 123 | background-color: white; 124 | } 125 | } 126 | } 127 | } 128 | 129 | 130 | 131 | // BUTTON 132 | .el-button{ 133 | background-color: $dark-btn-bg; 134 | border-color: $dark-border; 135 | color: $dark-text-main; 136 | 137 | &:active{ 138 | transform: translateY(2px); 139 | } 140 | 141 | &--default{ 142 | &.is-plain { 143 | background-color: $dark-btn-bg; 144 | &:hover { 145 | background-color: $blue; 146 | border-color: $blue; 147 | color: white; 148 | } 149 | } 150 | &.is-circle { 151 | background-color: $dark-blue; 152 | border-color: $dark-blue; 153 | &:hover { 154 | background-color: $blue; 155 | border-color: $blue; 156 | color: white; 157 | } 158 | } 159 | } 160 | 161 | &--success{ 162 | &.is-plain { 163 | background-color: $dark-btn-bg; 164 | &:hover { 165 | background-color: $green; 166 | border-color: $green; 167 | color: white; 168 | } 169 | } 170 | &.is-circle { 171 | background-color: $dark-green; 172 | border-color: $dark-green; 173 | &:hover { 174 | background-color: $green; 175 | border-color: $green; 176 | color: white; 177 | } 178 | } 179 | } 180 | &--danger{ 181 | &.is-plain{ 182 | border-color: $dark-border; 183 | background-color: $dark-btn-bg; 184 | &:hover{ 185 | background-color: $red; 186 | border-color: $red; 187 | } 188 | } 189 | &.is-circle { 190 | background-color: $dark-red; 191 | border-color: $dark-red; 192 | &:hover { 193 | background-color: $red; 194 | border-color: $red; 195 | color: white; 196 | } 197 | } 198 | } 199 | } 200 | 201 | // 文件拖放区 202 | .el-upload__tip{ 203 | color: $dark-text-info; 204 | } 205 | .el-upload-dragger{ 206 | background-color: $dark-uploader-bg; 207 | border-color: $dark-border; 208 | .el-upload__text{ 209 | color: $dark-text-info; 210 | } 211 | &:hover{ 212 | background: $dark-uploader-bg-highlight; 213 | border-color: $dark-border-highlight; 214 | } 215 | } 216 | 217 | // TABLE 218 | .el-table{ 219 | background-color: $dark-bg-td; 220 | &:before{ // 去除表格末尾的横线 221 | content: none; 222 | } 223 | &__header{ 224 | th{ 225 | border-bottom-color: $dark-border !important; 226 | } 227 | } 228 | th.el-table__cell{ 229 | background-color: $dark-bg-th; 230 | color: $dark-text-info; 231 | } 232 | td{ 233 | border-bottom-color: $dark-border !important; 234 | } 235 | tr{ 236 | background-color: $dark-bg-td; 237 | color: $dark-text-main; 238 | &:hover{ 239 | td{ 240 | background-color: $dark-bg-th !important; 241 | } 242 | } 243 | } 244 | } 245 | 246 | 247 | // ALERT 248 | .el-notification{ 249 | background-color: $dark-btn-bg-highlight; 250 | border-color: $dark-border; 251 | &__title{ 252 | color: white; 253 | } 254 | &__content{ 255 | color: $dark-text-info; 256 | } 257 | } 258 | 259 | // DIALOG 260 | .el-dialog{ 261 | background-color: $dark-dialog-bg; 262 | .el-dialog__header{ 263 | .el-dialog__title{ 264 | color: $dark-text-main; 265 | } 266 | } 267 | .el-dialog__body{ 268 | color: $dark-text-main; 269 | .el-input{ 270 | .el-input__inner{ 271 | border-color: $dark-border; 272 | color: $dark-text-main; 273 | background-color: $dark-btn-bg; 274 | } 275 | .el-input__suffix{ 276 | .el-input__suffix-inner{ 277 | } 278 | } 279 | .el-input__count{ 280 | .el-input__count-inner{ 281 | background-color: transparent; 282 | } 283 | } 284 | } 285 | } 286 | .item-desc{ 287 | color: $dark-text-info; 288 | } 289 | } 290 | 291 | } 292 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | cursor: pointer; 70 | &:active{ 71 | @include transform(translateY(2px)) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/scss/_variables.scss: -------------------------------------------------------------------------------- 1 | // COLORS 2 | $blue : #409EFF; 3 | $red : #F56C6C; 4 | $green : #85ce61; 5 | 6 | // TEXT COLOR 7 | $text-main : #2C3E50; 8 | $text-copyright : #777; 9 | $text-comment : #999; 10 | $color-link : $blue; 11 | 12 | // FONT SIZE 13 | $fz-main: 14px; 14 | $fz-mini-title: 13px; 15 | $fz-mini-content: 12px; 16 | 17 | // DARK MODE 18 | $dark-border : lighten(black, 25%); 19 | $dark-border-highlight : lighten(black, 55%); 20 | $dark-bg : lighten(black, 10%); 21 | $dark-text-main : lighten(black, 90%); 22 | $dark-text-info : lighten(black, 60%); 23 | $dark-uploader-bg : lighten(black, 13%); 24 | $dark-dialog-bg : lighten(black, 15%); 25 | $dark-uploader-bg-highlight : lighten(black, 18%); 26 | $dark-btn-bg : lighten(black, 20%); 27 | $dark-btn-bg-highlight : lighten(black, 30%); 28 | $dark-bg-th : lighten(black, 18%); 29 | $dark-bg-td : lighten(black, 13%); 30 | $dark-color-link : $green; 31 | 32 | $dark-blue : darken(desaturate($blue, 40%), 30%); 33 | $dark-red : darken(desaturate($red, 50%), 30%); 34 | $dark-green : darken(desaturate($green, 30%), 30%); 35 | -------------------------------------------------------------------------------- /src/scss/unlock-music.scss: -------------------------------------------------------------------------------- 1 | @import "variables"; 2 | @import "utility"; 3 | @import "gaps"; 4 | @import "element-ui-overwrite"; 5 | 6 | // MAIN CONTENT 7 | body{ 8 | margin: 0; 9 | padding: 0; 10 | border: 0; 11 | box-sizing: border-box; 12 | font-family: "PingFang SC", "微软雅黑", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif; 13 | font-size: $fz-main; 14 | -webkit-font-smoothing: antialiased; 15 | -moz-osx-font-smoothing: grayscale; 16 | } 17 | 18 | #app { 19 | text-align: center; 20 | color: $text-main; 21 | padding: 30px; 22 | } 23 | 24 | // 音频文件操作 25 | #app-control { 26 | margin-top: 20px; 27 | } 28 | 29 | // 音频播放 30 | audio{ 31 | margin-top: 20px; 32 | } 33 | 34 | .table-content{ 35 | margin-top: 20px; 36 | } 37 | 38 | // 编辑歌曲信息 39 | .music-cover{ 40 | margin-bottom: 20px; 41 | display: flex; 42 | justify-content: center; 43 | align-items: center; 44 | flex-flow: column nowrap; 45 | i{ 46 | margin-top: 10px; 47 | @extend .btn-like; 48 | &:hover{ 49 | color: $color-checkbox; 50 | } 51 | } 52 | .el-image{ 53 | padding: 5px; 54 | @include border-radius(5px); 55 | border: 1px solid $color-border-el; 56 | width: 150px; 57 | height: 150px; 58 | } 59 | } 60 | .edit-item{ 61 | display: flex; 62 | justify-content: flex-start; 63 | align-items: center; 64 | .label{ 65 | font-weight: bold; 66 | width: 80px; 67 | text-align: right; 68 | flex-shrink: 0; 69 | } 70 | .value{ 71 | padding: 5px 0; 72 | height: 20px; 73 | line-height: 20px; 74 | margin-left: 10px; 75 | overflow: hidden; 76 | white-space: nowrap; 77 | text-overflow: ellipsis; 78 | } 79 | .input{ 80 | margin-left: 10px; 81 | input{ 82 | font-family: inherit; 83 | height: 30px; 84 | line-height: 20px; 85 | @include border-radius(0); 86 | border: none; 87 | border-bottom: 1px solid $color-border-el; 88 | padding: 5px 5px; 89 | } 90 | } 91 | i{ 92 | margin-left: 10px; 93 | @extend .btn-like; 94 | &:hover{ 95 | color: $color-checkbox; 96 | } 97 | } 98 | } 99 | 100 | .tip{ 101 | margin-top: 20px; 102 | color: $text-comment; 103 | font-size: $fz-mini-content; 104 | a{ 105 | color: inherit; 106 | } 107 | } 108 | 109 | // footer 110 | #app-footer { 111 | margin-top: 40px; 112 | text-align: center; 113 | color: $text-copyright; 114 | line-height: 1.3; 115 | font-size: $fz-mini-content; 116 | a { 117 | padding-left: 0.2rem; 118 | padding-right: 0.2rem; 119 | color: darken($text-copyright, 10%); 120 | &:hover{ 121 | color: $color-link; 122 | } 123 | } 124 | } 125 | 126 | // 首页弹窗提示信息的 更新信息 面板 127 | .update-info{ 128 | @include border-radius(8px); 129 | overflow: hidden; 130 | border: 1px solid $color-border-el; 131 | margin: 10px 0; 132 | .update-title{ 133 | font-size: $fz-mini-title; 134 | padding: 3px 10px; 135 | background-color: $color-border-el; 136 | } 137 | .update-content{ 138 | font-size: $fz-mini-content; 139 | line-height: 1.5; 140 | padding: 5px 8px; 141 | } 142 | } 143 | 144 | @import "dark-mode"; // dark-mode 放在 normal 后面,以获得更高优先级 145 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/utils/__mocks__/qm_meta.ts: -------------------------------------------------------------------------------- 1 | export const extractQQMusicMeta = jest.fn(); 2 | -------------------------------------------------------------------------------- /src/utils/__mocks__/storage.ts: -------------------------------------------------------------------------------- 1 | export const storage = { 2 | loadJooxUUID: jest.fn(), 3 | saveJooxUUID: jest.fn(), 4 | }; 5 | -------------------------------------------------------------------------------- /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/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/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import storageFactory from './storage/StorageFactory'; 2 | 3 | export const storage = storageFactory(); 4 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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/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/worker.ts: -------------------------------------------------------------------------------- 1 | import { expose } from 'threads/worker'; 2 | import { Decrypt } from '@/decrypt'; 3 | 4 | expose(Decrypt); 5 | -------------------------------------------------------------------------------- /src/view/Home.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 265 | -------------------------------------------------------------------------------- /testdata/mflac0_rc4_key.bin: -------------------------------------------------------------------------------- 1 | dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5 -------------------------------------------------------------------------------- /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/mflac0_rc4_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac0_rc4_raw.bin -------------------------------------------------------------------------------- /testdata/mflac0_rc4_suffix.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac0_rc4_suffix.bin -------------------------------------------------------------------------------- /testdata/mflac0_rc4_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac0_rc4_target.bin -------------------------------------------------------------------------------- /testdata/mflac_map_key.bin: -------------------------------------------------------------------------------- 1 | yw7xWOyNQ8585Jwx3hjB49QLPKi38F89awnrQ0fq66NT9TDq1ppHNrFqhaDrU5AFk916D58I53h86304GqOFCCyFzBem68DqiXJ81bILEQwG3P3MOnoNzM820kNW9Lv9IJGNn9Xo497p82BLTm4hAX8JLBs0T2pilKvT429sK9jfg508GSk4d047Jxdz5Fou4aa33OkyFRBU3x430mgNBn04Lc9BzXUI2IGYXv3FGa9qE4Vb54kSjVv8ogbg47j3 -------------------------------------------------------------------------------- /testdata/mflac_map_key_raw.bin: -------------------------------------------------------------------------------- 1 | eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88= -------------------------------------------------------------------------------- /testdata/mflac_map_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac_map_raw.bin -------------------------------------------------------------------------------- /testdata/mflac_map_suffix.bin: -------------------------------------------------------------------------------- 1 | eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88=l -------------------------------------------------------------------------------- /testdata/mflac_map_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac_map_target.bin -------------------------------------------------------------------------------- /testdata/mflac_rc4_key.bin: -------------------------------------------------------------------------------- 1 | pUtyvqr0TgAvR95mNmY7DmNl386TsJNAEIz95CEcgIgJCcs28686O7llxD5E74ldn70xMtd5cG58TA5ILw09I8BOTf5EdHKd6wwPn689DUK13y3Req6H0P33my2miJ5bQ2AA22B8vp4V0NJ3hBqNtFf7cId48V6W51e1kwgu1xKKawxe9BByT92MFlqrFaKH32dB2zFgyd38l2P1outr4l2XLq48F9G17ptRz4W8Loxu28RvZgv0BzL26Ht9I2L5VCwMzzt7OeZ55iQs40Tr6k81QGraIUJj5zeBMgJRMTaSgi19hU5x5a08Qd662MbFhZZ0FjVvaDy1nbIDhrC62c1lX6wf70O45h4W42VxloBVeZ9Sef4V7cWrjrEjj3DJ5w2iu6Q9uoal2f4390kue42Um5HcDFWqv3m56k6O89bRV424PaRra1k9Cd2L56IN2zfBYqNo2WP5VC68G8w1hfflOY0O52h4WdcpoHSjZm4b35N7l47dT4dwEXj1U4J5 -------------------------------------------------------------------------------- /testdata/mflac_rc4_key_raw.bin: -------------------------------------------------------------------------------- 1 | cFV0eXZxcjAF/IXJ9qJT1u5C3S5AgY9BoVtIQNBKfxQMt5hH7BF36ndIJGV5L6qw5h4G0IOIOOewdHmMCNfKJftHM4nv3B0iRlSdqJKdL08wO3sV0v8eZk0OiYAlxgseGcBquQWYS/0b5Lj/Ioi2NfpOthAY9vUiRPnfH3+7/2AJGudHjj4Gg1KkpPW3mXIKbsk+Ou9fhrUqs873BCdsmI6qRmVNhOkLaUcbG6Zin3XU0WkgnnjebR43S8N4bw5BTphFvhy42QvspnD7Ewb1tVZQMQ2N1s38nBjukdfCB9R6aRwITOvg2U7Lr0RjLpbrIn6A6iVilpINjK4VptuKUTlpDXQwgCjoqeHQaHNCWgYpdjB69lXn8km/BfzK7QyDbh0VgTikwAHF9tvPhin3AIDRcU0xsaWYKURRfJelX3pSN495ADlhXdEKL/+l60hVnY7t6iCMxJL3lOtdGtdUYUGUCc76PB1fX+0HTWCcfcwvXTEdczr9J1h2yTeJNqFQ5pNy8vX7Ws8k7vDQVFkw4llZjPhb0kg9aDNePTNIKSGwy/7eofrcUQlC9DI+qqqwQ5abA/93fNsPq6XU3uwawnrbBsdz8DDdjJiEDI7abkPIDIfr/uR0YzgBxW90t5bt6xAtuW+VSYAM7kGxI3RZTl7JgOT60MLyIWkYASrRhRPMGks8zL10ED/4yGTEB1nt -------------------------------------------------------------------------------- /testdata/mflac_rc4_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac_rc4_raw.bin -------------------------------------------------------------------------------- /testdata/mflac_rc4_suffix.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac_rc4_suffix.bin -------------------------------------------------------------------------------- /testdata/mflac_rc4_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mflac_rc4_target.bin -------------------------------------------------------------------------------- /testdata/mgg_map_key.bin: -------------------------------------------------------------------------------- 1 | zGxNk54pKJ0hDkAo80wHE80ycSWQ7z4m4E846zVy2sqCn14F42Y5S7GqeR11WpOV75sDLbE5dFP992t88l0pHy1yAQ49YK6YX6c543drBYLo55Hc4Y0Fyic6LQPiGqu2bG31r8vaq9wS9v63kg0X5VbnOD6RhO4t0RRhk3ajrA7p0iIy027z0L70LZjtw6E18H0D41nz6ASTx71otdF9z1QNC0JmCl51xvnb39zPExEXyKkV47S6QsK5hFh884QJ -------------------------------------------------------------------------------- /testdata/mgg_map_key_raw.bin: -------------------------------------------------------------------------------- 1 | ekd4Tms1NHC53JEDO/AKVyF+I0bj0hHB7CZeoLDGSApaQB9Oo/pJTBGA/RO+nk5RXLXdHsffLiY4e8kt3LNo6qMl7S89vkiSFxx4Uoq4bGDJ7Jc+bYL6lLsa3M4sBvXS4XcPChrMDz+LmrJMGG6ua2fYyIz1d6TCRUBf1JJgCIkBbDAEeMVYc13qApitiz/apGAPmAnveCaDhfD5GxWsF+RfQ2OcnvrnIXe80Feh/0jx763DlsOBI3eIede6t5zYHokWkZmVEF1jMrnlvsgbQK2EzUWMblmLMsTKNILyZazEoKUyulqmyLO/c/KYE+USPOXPcbjlYFmLhSGHK7sQB5aBR153Yp+xh61ooh2NGAA= -------------------------------------------------------------------------------- /testdata/mgg_map_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mgg_map_raw.bin -------------------------------------------------------------------------------- /testdata/mgg_map_suffix.bin: -------------------------------------------------------------------------------- 1 | ekd4Tms1NHC53JEDO/AKVyF+I0bj0hHB7CZeoLDGSApaQB9Oo/pJTBGA/RO+nk5RXLXdHsffLiY4e8kt3LNo6qMl7S89vkiSFxx4Uoq4bGDJ7Jc+bYL6lLsa3M4sBvXS4XcPChrMDz+LmrJMGG6ua2fYyIz1d6TCRUBf1JJgCIkBbDAEeMVYc13qApitiz/apGAPmAnveCaDhfD5GxWsF+RfQ2OcnvrnIXe80Feh/0jx763DlsOBI3eIede6t5zYHokWkZmVEF1jMrnlvsgbQK2EzUWMblmLMsTKNILyZazEoKUyulqmyLO/c/KYE+USPOXPcbjlYFmLhSGHK7sQB5aBR153Yp+xh61ooh2NGAA=l -------------------------------------------------------------------------------- /testdata/mgg_map_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/mgg_map_target.bin -------------------------------------------------------------------------------- /testdata/qmc0_static_raw.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/qmc0_static_raw.bin -------------------------------------------------------------------------------- /testdata/qmc0_static_suffix.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/qmc0_static_suffix.bin -------------------------------------------------------------------------------- /testdata/qmc0_static_target.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/imsyy-lab/Unlock-Music/61fe20e6a87374fdf3aa475d1ea436a6e6671b08/testdata/qmc0_static_target.bin -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------