mirror of
https://git.um-react.app/um/web.git
synced 2025-12-13 02:33:01 +00:00
Compare commits
41 Commits
v1.9.0-bet
...
v1.10.0-be
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb9dc029a | ||
|
|
8151c79652 | ||
|
|
aa6476bb2c | ||
|
|
0f6dc53e54 | ||
|
|
58dfeee960 | ||
|
|
b4068e2edb | ||
|
|
cd3f4d8c22 | ||
|
|
522f4e9f67 | ||
|
|
439003f021 | ||
|
|
d6aa88f371 | ||
|
|
fb33e80484 | ||
|
|
d199647308 | ||
|
|
733c1721ed | ||
|
|
dbb5472f96 | ||
|
|
1111aaf3b7 | ||
|
|
f80cf29657 | ||
|
|
0a52d2a20b | ||
|
|
ada078df19 | ||
|
|
8facd65834 | ||
|
|
a6367401ae | ||
|
|
565ee37a8b | ||
|
|
aab611bff5 | ||
|
|
942e43fa16 | ||
|
|
84be57a903 | ||
|
|
a6a3c02538 | ||
|
|
216ff4021c | ||
|
|
d3898161b9 | ||
|
|
5a7a9e3add | ||
|
|
652bb1fc32 | ||
|
|
6737e8c11b | ||
|
|
71862538b7 | ||
|
|
4251b94b1f | ||
|
|
8fdda048f6 | ||
|
|
39c7294996 | ||
|
|
48f879cb58 | ||
|
|
f0875ad175 | ||
|
|
02a146e069 | ||
|
|
2e31853ffb | ||
|
|
a7aaf246ae | ||
|
|
4bc0a10c09 | ||
|
|
3645dd7d01 |
44
.github/workflows/build.yml
vendored
44
.github/workflows/build.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
push:
|
||||
paths:
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.vue"
|
||||
- "public/**/*"
|
||||
- "package-lock.json"
|
||||
@@ -12,14 +13,21 @@ on:
|
||||
types: [ opened, synchronize, reopened ]
|
||||
paths:
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.vue"
|
||||
- "public/**/*"
|
||||
- "package-lock.json"
|
||||
- "package.json"
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
test-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Test Coverage
|
||||
uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -35,40 +43,30 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node.js 14.x
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "14"
|
||||
node-version: "16"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm run fix-compatibility
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GZIP: "--best"
|
||||
run: |
|
||||
npm run build ${{ matrix.BUILD_ARGS }}
|
||||
tar -czvf dist.tar.gz -C ./dist .
|
||||
|
||||
- name: Build Extension
|
||||
if: ${{ matrix.BUILD_EXTENSION }}
|
||||
run: |
|
||||
npm run make-extension
|
||||
cd dist
|
||||
zip -rJ9 ../extension.zip *
|
||||
cd ..
|
||||
run: npm run build ${{ matrix.BUILD_ARGS }}
|
||||
|
||||
- name: Publish artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: unlock-music-${{ matrix.build }}.tar.gz
|
||||
path: ./dist.tar.gz
|
||||
name: ${{ matrix.build }}
|
||||
path: ./dist
|
||||
|
||||
- name: Build Extension
|
||||
if: ${{ matrix.BUILD_EXTENSION }}
|
||||
run: npm run make-extension
|
||||
|
||||
- name: Publish artifact - Extension
|
||||
if: ${{ matrix.BUILD_EXTENSION }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: extension.zip
|
||||
path: ./extension.zip
|
||||
name: extension
|
||||
path: ./dist
|
||||
|
||||
8
.github/workflows/release-build.yml
vendored
8
.github/workflows/release-build.yml
vendored
@@ -11,15 +11,13 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node.js 14.x
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: "14"
|
||||
node-version: "16"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm run fix-compatibility
|
||||
run: npm ci
|
||||
|
||||
- name: Build Legacy
|
||||
env:
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
/coverage
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
||||
4
LICENSE
4
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019-2020 MengYX
|
||||
Copyright (c) 2019-2021 MengYX
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
'@vue/app',
|
||||
'@babel/preset-typescript'
|
||||
],
|
||||
plugins: [
|
||||
["component", {
|
||||
|
||||
5
jest.config.js
Normal file
5
jest.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1'
|
||||
}
|
||||
};
|
||||
@@ -15,6 +15,9 @@ const manifest = JSON.parse(manifestRaw)
|
||||
const pkgRaw = fs.readFileSync("./package.json", "utf-8")
|
||||
const pkg = JSON.parse(pkgRaw)
|
||||
|
||||
manifest["version"] = pkg["version"]
|
||||
ver_str = pkg["version"]
|
||||
if (ver_str.startsWith("v")) ver_str = ver_str.slice(1)
|
||||
manifest["version"] = ver_str
|
||||
|
||||
fs.writeFileSync("./dist/manifest.json", JSON.stringify(manifest), "utf-8")
|
||||
console.log("Write: manifest.json")
|
||||
|
||||
31063
package-lock.json
generated
31063
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
31
package.json
31
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "unlock-music",
|
||||
"version": "v1.9.0-beta",
|
||||
"version": "v1.10.0-beta.1",
|
||||
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
|
||||
"license": "MIT",
|
||||
"description": "Unlock encrypted music file in browser.",
|
||||
@@ -10,38 +10,45 @@
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"serve": "vue-cli-service serve",
|
||||
"build": "vue-cli-service build",
|
||||
"fix-compatibility": "node ./src/fix-compatibility.js",
|
||||
"test": "jest",
|
||||
"make-extension": "node ./make-extension.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/preset-typescript": "^7.16.5",
|
||||
"@jixun/qmc2-crypto": "^0.0.5-R4",
|
||||
"base64-js": "^1.5.1",
|
||||
"browser-id3-writer": "^4.4.0",
|
||||
"core-js": "^3.12.1",
|
||||
"crypto-js": "^4.0.0",
|
||||
"element-ui": "^2.15.1",
|
||||
"core-js": "^3.16.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"element-ui": "^2.15.5",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jimp": "^0.16.1",
|
||||
"metaflac-js": "^1.0.5",
|
||||
"music-metadata-browser": "^2.2.6",
|
||||
"music-metadata": "7.9.0",
|
||||
"music-metadata-browser": "2.2.7",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"threads": "^1.6.4",
|
||||
"vue": "^2.6.12"
|
||||
"threads": "^1.6.5",
|
||||
"vue": "^2.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/crypto-js": "^4.0.1",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/jest": "^27.0.3",
|
||||
"@vue/cli-plugin-babel": "^4.5.13",
|
||||
"@vue/cli-plugin-pwa": "^4.5.13",
|
||||
"@vue/cli-plugin-typescript": "^4.5.13",
|
||||
"@vue/cli-service": "^4.5.13",
|
||||
"babel-plugin-component": "^1.1.1",
|
||||
"node-sass": "^5.0.0",
|
||||
"jest": "^27.4.5",
|
||||
"patch-package": "^6.4.7",
|
||||
"sass": "^1.38.1",
|
||||
"sass-loader": "^10.2.0",
|
||||
"semver": "^7.3.5",
|
||||
"threads-plugin": "^1.4.0",
|
||||
"typescript": "~4.1.5",
|
||||
"typescript": "^4.5.4",
|
||||
"vue-cli-plugin-element": "^1.0.1",
|
||||
"vue-template-compiler": "^2.6.12"
|
||||
"vue-template-compiler": "^2.6.14"
|
||||
}
|
||||
}
|
||||
|
||||
11
patches/threads+1.7.0.patch
Normal file
11
patches/threads+1.7.0.patch
Normal file
@@ -0,0 +1,11 @@
|
||||
diff --git a/node_modules/threads/worker.mjs b/node_modules/threads/worker.mjs
|
||||
index c53ac7d..619007b 100644
|
||||
--- a/node_modules/threads/worker.mjs
|
||||
+++ b/node_modules/threads/worker.mjs
|
||||
@@ -1,4 +1,5 @@
|
||||
-import WorkerContext from "./dist/worker/index.js"
|
||||
+// Workaround: use of import seems to break minifier.
|
||||
+const WorkerContext = require("./dist/worker/index.js")
|
||||
|
||||
export const expose = WorkerContext.expose
|
||||
export const registerSerializer = WorkerContext.registerSerializer
|
||||
@@ -63,7 +63,7 @@ export default {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (window.Worker && process.env.NODE_ENV === 'production') {
|
||||
if (window.Worker && window.location.protocol !== "file:" && process.env.NODE_ENV === 'production') {
|
||||
console.log("Using Worker Pool")
|
||||
this.queue = Pool(
|
||||
() => spawn(new Worker('@/utils/worker.ts')),
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
|
||||
import {Decrypt as NcmCacheDecrypt} from "@/decrypt/ncmcache";
|
||||
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
|
||||
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
|
||||
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
|
||||
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
|
||||
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
|
||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
||||
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
|
||||
import {DecryptResult, FileInfo} from "@/decrypt/entity";
|
||||
import {SplitFilename} from "@/decrypt/utils";
|
||||
|
||||
|
||||
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
||||
let raw_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
|
||||
let raw_filename = file.name.substring(0, file.name.lastIndexOf("."));
|
||||
const raw = SplitFilename(file.name)
|
||||
let rt_data: DecryptResult;
|
||||
switch (raw_ext) {
|
||||
switch (raw.ext) {
|
||||
case "ncm":// Netease Mp3/Flac
|
||||
rt_data = await NcmDecrypt(file.raw, raw_filename, raw_ext);
|
||||
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
|
||||
break;
|
||||
case "uc":// Netease Cache
|
||||
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
|
||||
break;
|
||||
case "kwm":// Kuwo Mp3/Flac
|
||||
rt_data = await KwmDecrypt(file.raw, raw_filename, raw_ext);
|
||||
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
|
||||
break
|
||||
case "xm": // Xiami Wav/M4a/Mp3/Flac
|
||||
case "wav":// Xiami/Raw Wav
|
||||
case "mp3":// Xiami/Raw Mp3
|
||||
case "flac":// Xiami/Raw Flac
|
||||
case "m4a":// Xiami/Raw M4a
|
||||
rt_data = await XmDecrypt(file.raw, raw_filename, raw_ext);
|
||||
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
|
||||
break;
|
||||
case "ogg":// Raw Ogg
|
||||
rt_data = await RawDecrypt(file.raw, raw_filename, raw_ext);
|
||||
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
|
||||
break;
|
||||
case "tm0":// QQ Music IOS Mp3
|
||||
case "tm3":// QQ Music IOS Mp3
|
||||
rt_data = await RawDecrypt(file.raw, raw_filename, "mp3");
|
||||
rt_data = await RawDecrypt(file.raw, raw.name, "mp3");
|
||||
break;
|
||||
case "qmc3"://QQ Music Android Mp3
|
||||
case "qmc2"://QQ Music Android Ogg
|
||||
@@ -41,30 +46,35 @@ export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
||||
case "tkm"://QQ Music Accompaniment M4a
|
||||
case "bkcmp3"://Moo Music Mp3
|
||||
case "bkcflac"://Moo Music Flac
|
||||
case "mflac"://QQ Music Desktop Flac
|
||||
case "mgg": //QQ Music Desktop Ogg
|
||||
case "mflac"://QQ Music New Flac
|
||||
case "mflac0"://QQ Music New Flac
|
||||
case "mgg": //QQ Music New Ogg
|
||||
case "mgg1": //QQ Music New Ogg
|
||||
case "666c6163"://QQ Music Weiyun Flac
|
||||
case "6d7033"://QQ Music Weiyun Mp3
|
||||
case "6f6767"://QQ Music Weiyun Ogg
|
||||
case "6d3461"://QQ Music Weiyun M4a
|
||||
case "776176"://QQ Music Weiyun Wav
|
||||
rt_data = await QmcDecrypt(file.raw, raw_filename, raw_ext);
|
||||
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
|
||||
break;
|
||||
case "tm2":// QQ Music IOS M4a
|
||||
case "tm6":// QQ Music IOS M4a
|
||||
rt_data = await TmDecrypt(file.raw, raw_filename);
|
||||
rt_data = await TmDecrypt(file.raw, raw.name);
|
||||
break;
|
||||
case "cache"://QQ Music Cache
|
||||
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
|
||||
break;
|
||||
case "vpr":
|
||||
case "kgm":
|
||||
case "kgma":
|
||||
rt_data = await KgmDecrypt(file.raw, raw_filename, raw_ext);
|
||||
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
|
||||
break
|
||||
default:
|
||||
throw "不支持此文件格式"
|
||||
}
|
||||
|
||||
if (!rt_data.rawExt) rt_data.rawExt = raw_ext;
|
||||
if (!rt_data.rawFilename) rt_data.rawFilename = raw_filename;
|
||||
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
|
||||
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
|
||||
console.log(rt_data);
|
||||
return rt_data;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
GetCoverFromFile,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt
|
||||
} from "@/decrypt/utils.ts";
|
||||
} from "@/decrypt/utils";
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
import config from "@/../package.json"
|
||||
|
||||
const VprHeader = [
|
||||
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
||||
@@ -22,9 +23,6 @@ const VprMaskDiff = [
|
||||
|
||||
|
||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||
if (window?.location?.protocol === "file:") {
|
||||
throw Error("请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
|
||||
}
|
||||
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||
if (raw_ext === "vpr") {
|
||||
@@ -84,8 +82,16 @@ function GetMask(pos: number) {
|
||||
let MaskV2: Uint8Array = new Uint8Array(0);
|
||||
|
||||
async function LoadMaskV2(): Promise<boolean> {
|
||||
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`
|
||||
if (["http:", "https:"].some(v => v == self.location.protocol)) {
|
||||
if (!!self.document) {// using Web Worker
|
||||
mask_url = "./static/kgm.mask"
|
||||
} else {// using Main thread
|
||||
mask_url = "../static/kgm.mask"
|
||||
}
|
||||
}
|
||||
try {
|
||||
const resp = await fetch("./static/kgm.mask", {method: "GET"})
|
||||
const resp = await fetch(mask_url, {method: "GET"})
|
||||
MaskV2 = new Uint8Array(await resp.arrayBuffer());
|
||||
return true
|
||||
} catch (e) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
GetCoverFromFile,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt
|
||||
} from "@/decrypt/utils.ts";
|
||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw.ts";
|
||||
} from "@/decrypt/utils";
|
||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
||||
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
|
||||
@@ -3,19 +3,27 @@ import {
|
||||
BytesHasPrefix,
|
||||
GetArrayBuffer,
|
||||
GetImageFromURL,
|
||||
GetMetaFromFile, IMusicMeta,
|
||||
GetMetaFromFile,
|
||||
IMusicMeta,
|
||||
SniffAudioExt,
|
||||
WriteMetaToFlac,
|
||||
WriteMetaToMp3
|
||||
} from "@/decrypt/utils.ts";
|
||||
} from "@/decrypt/utils";
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
import jimp from 'jimp';
|
||||
|
||||
import CryptoJS from "crypto-js";
|
||||
import AES from "crypto-js/aes";
|
||||
import PKCS7 from "crypto-js/pad-pkcs7";
|
||||
import ModeECB from "crypto-js/mode-ecb";
|
||||
import WordArray from "crypto-js/lib-typedarrays";
|
||||
import Base64 from "crypto-js/enc-base64";
|
||||
import EncUTF8 from "crypto-js/enc-utf8";
|
||||
import EncHex from "crypto-js/enc-hex";
|
||||
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
|
||||
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
|
||||
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
|
||||
const CORE_KEY = EncHex.parse("687a4852416d736f356b496e62617857");
|
||||
const META_KEY = EncHex.parse("2331346C6A6B5F215C5D2630553C2728");
|
||||
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
|
||||
|
||||
|
||||
@@ -67,11 +75,11 @@ class NcmDecrypt {
|
||||
.map(uint8 => uint8 ^ 0x64);
|
||||
this.offset += keyLen;
|
||||
|
||||
const plainText = CryptoJS.AES.decrypt(
|
||||
const plainText = AES.decrypt(
|
||||
// @ts-ignore
|
||||
{ciphertext: CryptoJS.lib.WordArray.create(cipherText)},
|
||||
{ciphertext: WordArray.create(cipherText)},
|
||||
CORE_KEY,
|
||||
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
|
||||
{mode: ModeECB, padding: PKCS7}
|
||||
);
|
||||
|
||||
const result = new Uint8Array(plainText.sigBytes);
|
||||
@@ -115,17 +123,18 @@ class NcmDecrypt {
|
||||
.map(data => data ^ 0x63);
|
||||
this.offset += metaDataLen;
|
||||
|
||||
const plainText = CryptoJS.AES.decrypt(
|
||||
//@ts-ignore
|
||||
WordArray.create()
|
||||
const plainText = AES.decrypt(
|
||||
// @ts-ignore
|
||||
{
|
||||
ciphertext: CryptoJS.enc.Base64.parse(
|
||||
//@ts-ignore
|
||||
CryptoJS.lib.WordArray.create(cipherText.slice(22)).toString(CryptoJS.enc.Utf8)
|
||||
ciphertext: Base64.parse(
|
||||
// @ts-ignore
|
||||
WordArray.create(cipherText.slice(22)).toString(EncUTF8)
|
||||
)
|
||||
},
|
||||
META_KEY,
|
||||
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
|
||||
).toString(CryptoJS.enc.Utf8);
|
||||
{mode: ModeECB, padding: PKCS7}
|
||||
).toString(EncUTF8);
|
||||
|
||||
const labelIndex = plainText.indexOf(":");
|
||||
let result: NcmMusicMeta;
|
||||
@@ -201,7 +210,7 @@ class NcmDecrypt {
|
||||
}
|
||||
|
||||
gatherResult(): DecryptResult {
|
||||
if (!this.newMeta) throw Error("bad sequence")
|
||||
if (!this.newMeta || !this.blob) throw Error("bad sequence")
|
||||
return {
|
||||
title: this.newMeta.title,
|
||||
artist: this.newMeta.artists?.join("; "),
|
||||
@@ -209,7 +218,7 @@ class NcmDecrypt {
|
||||
album: this.newMeta.album,
|
||||
picture: this.image?.url,
|
||||
file: URL.createObjectURL(this.blob),
|
||||
blob: this.blob as Blob,
|
||||
blob: this.blob,
|
||||
mime: this.mime
|
||||
}
|
||||
}
|
||||
|
||||
29
src/decrypt/ncmcache.ts
Normal file
29
src/decrypt/ncmcache.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils";
|
||||
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
|
||||
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
|
||||
: Promise<DecryptResult> {
|
||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||
let length = buffer.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
buffer[i] ^= 163
|
||||
}
|
||||
const ext = SniffAudioExt(buffer, raw_ext);
|
||||
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
|
||||
const tag = await metaParseBlob(file);
|
||||
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
|
||||
|
||||
return {
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
album: tag.common.album,
|
||||
picture: GetCoverFromFile(tag),
|
||||
file: URL.createObjectURL(file),
|
||||
blob: file,
|
||||
mime: AudioMimeType[ext]
|
||||
}
|
||||
}
|
||||
@@ -1,138 +1,139 @@
|
||||
import {QmcMask, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask";
|
||||
import {toByteArray as Base64Decode} from 'base64-js'
|
||||
import {QmcStaticCipher} from "./qmc_cipher";
|
||||
import {
|
||||
AudioMimeType,
|
||||
GetArrayBuffer,
|
||||
GetCoverFromFile,
|
||||
GetImageFromURL,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt, WriteMetaToFlac, WriteMetaToMp3
|
||||
} from "@/decrypt/utils.ts";
|
||||
AudioMimeType,
|
||||
GetArrayBuffer,
|
||||
GetCoverFromFile,
|
||||
GetImageFromURL,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt,
|
||||
WriteMetaToFlac,
|
||||
WriteMetaToMp3
|
||||
} from "@/decrypt/utils";
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
import {DecryptQMCv2} from "./qmcv2";
|
||||
|
||||
|
||||
import iconv from "iconv-lite";
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
import {queryAlbumCover, queryKeyInfo, reportKeyUsage} from "@/utils/api";
|
||||
import {queryAlbumCover} from "@/utils/api";
|
||||
|
||||
interface Handler {
|
||||
ext: string
|
||||
detect: boolean
|
||||
|
||||
handler(data?: Uint8Array): QmcMask | undefined
|
||||
ext: string
|
||||
version: number
|
||||
}
|
||||
|
||||
const HandlerMap: { [key: string]: Handler } = {
|
||||
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
|
||||
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
|
||||
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
||||
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
||||
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
||||
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
||||
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
|
||||
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
||||
"6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
||||
"6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
|
||||
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
|
||||
export const HandlerMap: { [key: string]: Handler } = {
|
||||
"mgg": {ext: "ogg", version: 2},
|
||||
"mgg1": {ext: "ogg", version: 2},
|
||||
"mflac": {ext: "flac", version: 2},
|
||||
"mflac0": {ext: "flac", version: 2},
|
||||
|
||||
// qmcflac / qmcogg:
|
||||
// 有可能是 v2 加密但混用同一个后缀名。
|
||||
"qmcflac": {ext: "flac", version: 2},
|
||||
"qmcogg": {ext: "ogg", version: 2},
|
||||
|
||||
"qmc0": {ext: "mp3", version: 1},
|
||||
"qmc2": {ext: "ogg", version: 1},
|
||||
"qmc3": {ext: "mp3", version: 1},
|
||||
"bkcmp3": {ext: "mp3", version: 1},
|
||||
"bkcflac": {ext: "flac", version: 1},
|
||||
"tkm": {ext: "m4a", version: 1},
|
||||
"666c6163": {ext: "flac", version: 1},
|
||||
"6d7033": {ext: "mp3", version: 1},
|
||||
"6f6767": {ext: "ogg", version: 1},
|
||||
"6d3461": {ext: "m4a", version: 1},
|
||||
"776176": {ext: "wav", version: 1}
|
||||
};
|
||||
|
||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||
if (!(raw_ext in HandlerMap)) throw "File type is incorrect!";
|
||||
const handler = HandlerMap[raw_ext];
|
||||
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
|
||||
const handler = HandlerMap[raw_ext];
|
||||
let {version} = handler;
|
||||
|
||||
const fileData = new Uint8Array(await GetArrayBuffer(file));
|
||||
let audioData, seed, keyData;
|
||||
if (handler.detect) {
|
||||
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
|
||||
const keyPos = fileData.length - 4 - keyLen;
|
||||
audioData = fileData.slice(0, keyPos);
|
||||
seed = handler.handler(audioData);
|
||||
keyData = fileData.slice(keyPos, keyPos + keyLen);
|
||||
if (!seed) seed = await queryKey(keyData, raw_filename, raw_ext);
|
||||
if (!seed) throw raw_ext + "格式仅提供实验性支持";
|
||||
const fileBuffer = await GetArrayBuffer(file);
|
||||
let musicDecoded: Uint8Array | undefined;
|
||||
|
||||
if (version === 2) {
|
||||
const v2Decrypted = await DecryptQMCv2(fileBuffer);
|
||||
// 如果 v2 检测失败,降级到 v1 再尝试一次
|
||||
if (v2Decrypted) {
|
||||
musicDecoded = v2Decrypted;
|
||||
} else {
|
||||
audioData = fileData;
|
||||
seed = handler.handler(audioData) as QmcMask;
|
||||
version = 1;
|
||||
}
|
||||
let musicDecoded = seed.Decrypt(audioData);
|
||||
}
|
||||
|
||||
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
||||
const mime = AudioMimeType[ext];
|
||||
if (version === 1) {
|
||||
const seed = new QmcStaticCipher();
|
||||
musicDecoded = new Uint8Array(fileBuffer)
|
||||
seed.decrypt(musicDecoded, 0);
|
||||
} else if (!musicDecoded) {
|
||||
throw new Error(`解密失败: ${raw_ext}`);
|
||||
}
|
||||
|
||||
let musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
||||
const mime = AudioMimeType[ext];
|
||||
|
||||
const musicMeta = await metaParseBlob(musicBlob);
|
||||
for (let metaIdx in musicMeta.native) {
|
||||
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
|
||||
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
|
||||
console.warn("try using gbk encoding to decode meta")
|
||||
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
|
||||
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
|
||||
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
|
||||
let musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
|
||||
const musicMeta = await metaParseBlob(musicBlob);
|
||||
for (let metaIdx in musicMeta.native) {
|
||||
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
|
||||
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
|
||||
console.warn("try using gbk encoding to decode meta")
|
||||
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
|
||||
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
|
||||
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
|
||||
}
|
||||
}
|
||||
|
||||
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
|
||||
|
||||
let imgUrl = GetCoverFromFile(musicMeta);
|
||||
if (!imgUrl) {
|
||||
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
|
||||
if (imgUrl) {
|
||||
const imageInfo = await GetImageFromURL(imgUrl);
|
||||
if (imageInfo) {
|
||||
imgUrl = imageInfo.url
|
||||
try {
|
||||
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
|
||||
if (ext === "mp3") {
|
||||
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
|
||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
} else if (ext === 'flac') {
|
||||
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
|
||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
} else {
|
||||
console.info("writing metadata for " + ext + " is not being supported for now")
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error while appending cover image to file " + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
|
||||
if (keyData) reportKeyUsage(keyData, seed.getMatrix128(),
|
||||
raw_filename, raw_ext, info.title, info.artist, musicMeta.common.album).then().catch();
|
||||
|
||||
let imgUrl = GetCoverFromFile(musicMeta);
|
||||
if (!imgUrl) {
|
||||
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
|
||||
if (imgUrl) {
|
||||
const imageInfo = await GetImageFromURL(imgUrl);
|
||||
if (imageInfo) {
|
||||
imgUrl = imageInfo.url
|
||||
try {
|
||||
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
|
||||
if (ext === "mp3") {
|
||||
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
|
||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
} else if (ext === 'flac') {
|
||||
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
|
||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
} else {
|
||||
console.info("writing metadata for " + ext + " is not being supported for now")
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error while appending cover image to file " + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: musicMeta.common.album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
blob: musicBlob,
|
||||
mime: mime
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: musicMeta.common.album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
blob: musicBlob,
|
||||
mime: mime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function queryKey(keyData: Uint8Array, filename: string, format: string): Promise<QmcMask | undefined> {
|
||||
try {
|
||||
const data = await queryKeyInfo(keyData, filename, format)
|
||||
return new QmcMask(Base64Decode(data.Matrix44));
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
||||
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
|
||||
try {
|
||||
const data = await queryAlbumCover(title, artist, album)
|
||||
return `${song_query_url}/${data.Type}/${data.Id}`
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
return ""
|
||||
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
|
||||
try {
|
||||
const data = await queryAlbumCover(title, artist, album)
|
||||
return `${song_query_url}/${data.Type}/${data.Id}`
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
import {BytesHasPrefix, FLAC_HEADER, OGG_HEADER} from "@/decrypt/utils.ts";
|
||||
|
||||
const QMOggPublicHeader1 = [
|
||||
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff,
|
||||
0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72,
|
||||
0x62, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0xee, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x4f, 0x67, 0x67, 0x53, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00,
|
||||
0xff, 0xff, 0xff, 0xff];
|
||||
const QMOggPublicHeader2 = [
|
||||
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x2c, 0x00, 0x00, 0x00, 0x58, 0x69, 0x70, 0x68, 0x2e,
|
||||
0x4f, 0x72, 0x67, 0x20, 0x6c, 0x69, 0x62, 0x56, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x20, 0x49, 0x20,
|
||||
0x32, 0x30, 0x31, 0x35, 0x30, 0x31, 0x30, 0x35, 0x20, 0x28, 0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84,
|
||||
0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84, 0x29, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x54,
|
||||
0x49, 0x54, 0x4c, 0x45, 0x3d];
|
||||
const QMOggPublicConf1 = [
|
||||
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0,
|
||||
0, 0, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9,
|
||||
9, 9, 9, 9, 9, 9, 9, 6, 3, 3, 3, 3, 6, 6, 6, 6,
|
||||
3, 3, 3, 3, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
|
||||
9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9,
|
||||
0, 0, 0, 0];
|
||||
const QMOggPublicConf2 = [
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
||||
3, 3, 3, 3, 3, 3, 3, 0, 1, 3, 3, 0, 1, 3, 3, 3,
|
||||
3, 3, 3, 3, 3];
|
||||
const QMCDefaultMaskMatrix = [
|
||||
0xde, 0x51, 0xfa, 0xc3, 0x4a, 0xd6, 0xca, 0x90,
|
||||
0x7e, 0x67, 0x5e, 0xf7, 0xd5, 0x52, 0x84, 0xd8,
|
||||
0x47, 0x95, 0xbb, 0xa1, 0xaa, 0xc6, 0x66, 0x23,
|
||||
0x92, 0x62, 0xf3, 0x74, 0xa1, 0x9f, 0xf4, 0xa0,
|
||||
0x1d, 0x3f, 0x5b, 0xf0, 0x13, 0x0e, 0x09, 0x3d,
|
||||
0xf9, 0xbc, 0x00, 0x11];
|
||||
|
||||
|
||||
const AllMapping: number[][] = [];
|
||||
const Mask128to44: number[] = [];
|
||||
|
||||
(function () {
|
||||
for (let i = 0; i < 128; i++) {
|
||||
let realIdx = (i * i + 27) % 256
|
||||
if (realIdx in AllMapping) {
|
||||
AllMapping[realIdx].push(i)
|
||||
} else {
|
||||
AllMapping[realIdx] = [i]
|
||||
}
|
||||
}
|
||||
|
||||
let idx44 = 0
|
||||
AllMapping.forEach(all128 => {
|
||||
all128.forEach(_i128 => {
|
||||
Mask128to44[_i128] = idx44
|
||||
})
|
||||
idx44++
|
||||
})
|
||||
})();
|
||||
|
||||
|
||||
export class QmcMask {
|
||||
private readonly Matrix128: number[];
|
||||
|
||||
constructor(matrix: number[] | Uint8Array) {
|
||||
if (matrix instanceof Uint8Array) matrix = Array.from(matrix)
|
||||
if (matrix.length === 44) {
|
||||
this.Matrix128 = this._generate128(matrix)
|
||||
} else if (matrix.length === 128) {
|
||||
this.Matrix128 = matrix
|
||||
} else {
|
||||
throw Error("invalid mask length")
|
||||
}
|
||||
}
|
||||
|
||||
getMatrix128() {
|
||||
return this.Matrix128
|
||||
}
|
||||
|
||||
getMatrix44(): number[] {
|
||||
const matrix44: number[] = []
|
||||
let idxI44 = 0
|
||||
AllMapping.forEach(it256 => {
|
||||
let it256Len = it256.length
|
||||
for (let i = 1; i < it256Len; i++) {
|
||||
if (this.Matrix128[it256[0]] !== this.Matrix128[it256[i]]) {
|
||||
throw "decode mask-128 to mask-44 failed"
|
||||
}
|
||||
}
|
||||
matrix44[idxI44] = this.Matrix128[it256[0]]
|
||||
idxI44++
|
||||
})
|
||||
return matrix44
|
||||
}
|
||||
|
||||
Decrypt(data: Uint8Array) {
|
||||
if (!this.Matrix128) throw Error("bad call sequence")
|
||||
let dst = data.slice(0);
|
||||
let index = -1;
|
||||
let maskIdx = -1;
|
||||
for (let cur = 0; cur < data.length; cur++) {
|
||||
index++;
|
||||
maskIdx++;
|
||||
if (index === 0x8000 || (index > 0x8000 && (index + 1) % 0x8000 === 0)) {
|
||||
index++;
|
||||
maskIdx++;
|
||||
}
|
||||
if (maskIdx >= 128) maskIdx -= 128;
|
||||
dst[cur] ^= this.Matrix128[maskIdx];
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
private _generate128(matrix44: number[]): number[] {
|
||||
const matrix128: number[] = []
|
||||
let idx44 = 0
|
||||
AllMapping.forEach(it256 => {
|
||||
it256.forEach(m => {
|
||||
matrix128[m] = matrix44[idx44]
|
||||
})
|
||||
idx44++
|
||||
})
|
||||
return matrix128
|
||||
}
|
||||
}
|
||||
|
||||
export function QmcMaskGetDefault() {
|
||||
return new QmcMask(QMCDefaultMaskMatrix)
|
||||
}
|
||||
|
||||
export function QmcMaskDetectMflac(data: Uint8Array) {
|
||||
let search_len = Math.min(0x8000, data.length), mask;
|
||||
for (let block_idx = 0; block_idx < search_len; block_idx += 128) {
|
||||
try {
|
||||
mask = new QmcMask(data.slice(block_idx, block_idx + 128));
|
||||
if (BytesHasPrefix(mask.Decrypt(data.slice(0, FLAC_HEADER.length)), FLAC_HEADER)) {
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function QmcMaskDetectMgg(data: Uint8Array) {
|
||||
if (data.length < 0x100) return
|
||||
let matrixConfidence: { [key: number]: { [key: number]: number } } = {};
|
||||
for (let i = 0; i < 44; i++) matrixConfidence[i] = {};
|
||||
|
||||
const page2 = data[0x54] ^ data[0xC] ^ QMOggPublicHeader1[0xC];
|
||||
const spHeader = QmcGenerateOggHeader(page2)
|
||||
const spConf = QmcGenerateOggConf(page2)
|
||||
|
||||
for (let idx128 = 0; idx128 < spHeader.length; idx128++) {
|
||||
if (spConf[idx128] === 0) continue;
|
||||
let idx44 = Mask128to44[idx128 % 128];
|
||||
let _m = data[idx128] ^ spHeader[idx128]
|
||||
let confidence = spConf[idx128];
|
||||
if (_m in matrixConfidence[idx44]) {
|
||||
matrixConfidence[idx44][_m] += confidence
|
||||
} else {
|
||||
matrixConfidence[idx44][_m] = confidence
|
||||
}
|
||||
}
|
||||
let matrix = [];
|
||||
try {
|
||||
for (let i = 0; i < 44; i++)
|
||||
matrix[i] = calcMaskFromConfidence(matrixConfidence[i]);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const mask = new QmcMask(matrix);
|
||||
if (!BytesHasPrefix(mask.Decrypt(data.slice(0, OGG_HEADER.length)), OGG_HEADER)) {
|
||||
return;
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
|
||||
function calcMaskFromConfidence(confidence: { [key: number]: number }) {
|
||||
const count = Object.keys(confidence).length
|
||||
if (count === 0) throw "can not match at least one key";
|
||||
if (count > 1) console.warn("There are 2 potential value for the mask!")
|
||||
let result = ""
|
||||
let conf = 0
|
||||
for (let idx in confidence) {
|
||||
if (confidence[idx] > conf) {
|
||||
result = idx;
|
||||
conf = confidence[idx];
|
||||
}
|
||||
}
|
||||
return Number(result)
|
||||
}
|
||||
|
||||
function QmcGenerateOggHeader(page2: number) {
|
||||
let spec = [page2, 0xFF]
|
||||
for (let i = 2; i < page2; i++) spec.push(0xFF)
|
||||
spec.push(0xFF)
|
||||
return QMOggPublicHeader1.concat(spec, QMOggPublicHeader2)
|
||||
}
|
||||
|
||||
function QmcGenerateOggConf(page2: number) {
|
||||
let specConf = [6, 0]
|
||||
for (let i = 2; i < page2; i++) specConf.push(4)
|
||||
specConf.push(0)
|
||||
return QMOggPublicConf1.concat(specConf, QMOggPublicConf2)
|
||||
}
|
||||
27
src/decrypt/qmc_cipher.test.ts
Normal file
27
src/decrypt/qmc_cipher.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {QmcStaticCipher} from "@/decrypt/qmc_cipher";
|
||||
|
||||
test("static cipher [0x7ff8,0x8000) ", () => {
|
||||
const expected = new Uint8Array([
|
||||
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
|
||||
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
|
||||
])
|
||||
|
||||
const c = new QmcStaticCipher()
|
||||
const buf = new Uint8Array(16)
|
||||
c.decrypt(buf, 0x7ff8)
|
||||
|
||||
expect(buf).toStrictEqual(expected)
|
||||
})
|
||||
|
||||
test("static cipher [0,0x10) ", () => {
|
||||
const expected = new Uint8Array([
|
||||
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
|
||||
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
|
||||
])
|
||||
|
||||
const c = new QmcStaticCipher()
|
||||
const buf = new Uint8Array(16)
|
||||
c.decrypt(buf, 0)
|
||||
|
||||
expect(buf).toStrictEqual(expected)
|
||||
})
|
||||
53
src/decrypt/qmc_cipher.ts
Normal file
53
src/decrypt/qmc_cipher.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
const staticCipherBox = new Uint8Array([
|
||||
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
|
||||
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
|
||||
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10
|
||||
0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18
|
||||
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20
|
||||
0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28
|
||||
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30
|
||||
0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38
|
||||
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40
|
||||
0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48
|
||||
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50
|
||||
0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58
|
||||
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60
|
||||
0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68
|
||||
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70
|
||||
0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78
|
||||
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80
|
||||
0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88
|
||||
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90
|
||||
0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98
|
||||
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0
|
||||
0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8
|
||||
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0
|
||||
0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8
|
||||
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0
|
||||
0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8
|
||||
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0
|
||||
0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8
|
||||
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0
|
||||
0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8
|
||||
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0
|
||||
0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8
|
||||
])
|
||||
|
||||
interface streamCipher {
|
||||
decrypt(buf: Uint8Array, offset: number): void
|
||||
}
|
||||
|
||||
export class QmcStaticCipher implements streamCipher {
|
||||
|
||||
public getMask(offset: number) {
|
||||
if (offset > 0x7FFF) offset %= 0x7FFF
|
||||
return staticCipherBox[(offset * offset + 27) & 0xff]
|
||||
}
|
||||
|
||||
public decrypt(buf: Uint8Array, offset: number) {
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
buf[i] ^= this.getMask(offset + i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
src/decrypt/qmccache.ts
Normal file
51
src/decrypt/qmccache.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
AudioMimeType,
|
||||
GetArrayBuffer,
|
||||
GetCoverFromFile,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt,
|
||||
SplitFilename
|
||||
} from "@/decrypt/utils";
|
||||
|
||||
import {Decrypt as QmcDecrypt, HandlerMap} from "@/decrypt/qmc";
|
||||
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
|
||||
export async function Decrypt(file: Blob, raw_filename: string, _: string)
|
||||
: Promise<DecryptResult> {
|
||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||
let length = buffer.length
|
||||
for (let i = 0; i < length; i++) {
|
||||
buffer[i] ^= 0xf4
|
||||
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
|
||||
else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
|
||||
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
|
||||
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
|
||||
}
|
||||
let ext = SniffAudioExt(buffer, "");
|
||||
const newName = SplitFilename(raw_filename)
|
||||
let audioBlob: Blob
|
||||
if (ext !== "" || newName.ext === "mp3") {
|
||||
audioBlob = new Blob([buffer], {type: AudioMimeType[ext]})
|
||||
} else if (newName.ext in HandlerMap) {
|
||||
audioBlob = new Blob([buffer], {type: "application/octet-stream"})
|
||||
return QmcDecrypt(audioBlob, newName.name, newName.ext);
|
||||
} else {
|
||||
throw "不支持的QQ音乐缓存格式"
|
||||
}
|
||||
const tag = await metaParseBlob(audioBlob);
|
||||
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
|
||||
|
||||
return {
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
album: tag.common.album,
|
||||
picture: GetCoverFromFile(tag),
|
||||
file: URL.createObjectURL(audioBlob),
|
||||
blob: audioBlob,
|
||||
mime: AudioMimeType[ext]
|
||||
}
|
||||
}
|
||||
102
src/decrypt/qmcv2.ts
Normal file
102
src/decrypt/qmcv2.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
|
||||
|
||||
// 检测文件末端使用的缓冲区大小
|
||||
const DETECTION_SIZE = 40;
|
||||
|
||||
// 每次处理 2M 的数据
|
||||
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
function MergeUint8Array(array: Uint8Array[]): Uint8Array {
|
||||
let length = 0;
|
||||
array.forEach(item => {
|
||||
length += item.length;
|
||||
});
|
||||
|
||||
let mergedArray = new Uint8Array(length);
|
||||
let offset = 0;
|
||||
array.forEach(item => {
|
||||
mergedArray.set(item, offset);
|
||||
offset += item.length;
|
||||
});
|
||||
|
||||
return mergedArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密一个 QMC2 加密的文件。
|
||||
*
|
||||
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
|
||||
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
|
||||
* @return {Promise<Uint8Array|false>}
|
||||
*/
|
||||
export async function DecryptQMCv2(mggBlob: ArrayBuffer) {
|
||||
// 初始化模组
|
||||
const QMCCrypto = await QMCCryptoModule();
|
||||
|
||||
// 申请内存块,并文件末端数据到 WASM 的内存堆
|
||||
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
|
||||
const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length);
|
||||
QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf);
|
||||
|
||||
// 检测结果内存块
|
||||
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
|
||||
|
||||
// 进行检测
|
||||
const detectOK = QMCCrypto.detectKeyEndPosition(
|
||||
pDetectionResult,
|
||||
pDetectionBuf,
|
||||
detectionBuf.length
|
||||
);
|
||||
|
||||
// 提取结构体内容:
|
||||
// (pos: i32; len: i32; error: char[??])
|
||||
const position = QMCCrypto.getValue(pDetectionResult, "i32");
|
||||
const len = QMCCrypto.getValue(pDetectionResult + 4, "i32");
|
||||
|
||||
// 释放内存
|
||||
QMCCrypto._free(pDetectionBuf);
|
||||
QMCCrypto._free(pDetectionResult);
|
||||
|
||||
if (!detectOK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 计算解密后文件的大小。
|
||||
// 之前得到的 position 为相对当前检测数据起点的偏移。
|
||||
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
|
||||
|
||||
// 提取嵌入到文件的 EKey
|
||||
const ekey = new Uint8Array(
|
||||
mggBlob.slice(decryptedSize, decryptedSize + len)
|
||||
);
|
||||
|
||||
// 解码 UTF-8 数据到 string
|
||||
const decoder = new TextDecoder();
|
||||
const ekey_b64 = decoder.decode(ekey);
|
||||
|
||||
// 初始化加密与缓冲区
|
||||
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64);
|
||||
const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE);
|
||||
|
||||
const decryptedParts = [];
|
||||
let offset = 0;
|
||||
let bytesToDecrypt = decryptedSize;
|
||||
while (bytesToDecrypt > 0) {
|
||||
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
|
||||
|
||||
// 解密一些片段
|
||||
const blockData = new Uint8Array(
|
||||
mggBlob.slice(offset, offset + blockSize)
|
||||
);
|
||||
QMCCrypto.writeArrayToMemory(blockData, buf);
|
||||
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
|
||||
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
|
||||
|
||||
offset += blockSize;
|
||||
bytesToDecrypt -= blockSize;
|
||||
}
|
||||
QMCCrypto._free(buf);
|
||||
hCrypto.delete();
|
||||
|
||||
return MergeUint8Array(decryptedParts);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts";
|
||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils";
|
||||
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Decrypt as RawDecrypt} from "./raw";
|
||||
import {GetArrayBuffer} from "@/decrypt/utils.ts";
|
||||
import {GetArrayBuffer} from "@/decrypt/utils";
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
|
||||
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
||||
|
||||
@@ -12,13 +12,16 @@ export const WMA_HEADER = [
|
||||
]
|
||||
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
|
||||
export const AAC_HEADER = [0xFF, 0xF1]
|
||||
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
|
||||
|
||||
export const AudioMimeType: { [key: string]: string } = {
|
||||
mp3: "audio/mpeg",
|
||||
flac: "audio/flac",
|
||||
m4a: "audio/mp4",
|
||||
ogg: "audio/ogg",
|
||||
wma: "audio/x-ms-wma",
|
||||
wav: "audio/x-wav"
|
||||
wav: "audio/x-wav",
|
||||
dff: "audio/x-dff"
|
||||
};
|
||||
|
||||
|
||||
@@ -39,6 +42,7 @@ export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): s
|
||||
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
|
||||
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
|
||||
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
|
||||
if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
|
||||
return fallback_ext;
|
||||
}
|
||||
|
||||
@@ -156,3 +160,11 @@ export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: I
|
||||
}
|
||||
return writer.save()
|
||||
}
|
||||
|
||||
export function SplitFilename(n: string): { name: string; ext: string } {
|
||||
const pos = n.lastIndexOf(".")
|
||||
return {
|
||||
ext: n.substring(pos + 1).toLowerCase(),
|
||||
name: n.substring(0, pos)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
||||
import {DecryptResult} from "@/decrypt/entity";
|
||||
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils.ts";
|
||||
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils";
|
||||
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//TODO: Use other method to fix this
|
||||
// !! Only Temporary Solution
|
||||
// it seems like that @babel/plugin-proposal-object-rest-spread not working
|
||||
// to fix up the compatibility for Edge 18 and some older Chromium
|
||||
// now manually edit the dependency files
|
||||
|
||||
const fs = require('fs');
|
||||
const filePath = "./node_modules/file-type/core.js";
|
||||
const regReplace = /{\s*([a-zA-Z0-9:,\s]*),\s*\.\.\.([a-zA-Z0-9]*)\s*};/m;
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log("File Found!");
|
||||
let data = fs.readFileSync(filePath).toString();
|
||||
const regResult = regReplace.exec(data);
|
||||
if (regResult != null) {
|
||||
data = data.replace(regResult[0],
|
||||
"Object.assign({ " + regResult[1] + " }, " + regResult[2] + ");"
|
||||
);
|
||||
fs.writeFileSync(filePath, data);
|
||||
console.log("Object rest spread in file-type fixed!");
|
||||
} else {
|
||||
console.log("No fix needed.");
|
||||
}
|
||||
} else {
|
||||
console.log("File Not Found!");
|
||||
}
|
||||
9
src/shims-fs.d.ts
vendored
9
src/shims-fs.d.ts
vendored
@@ -6,6 +6,10 @@ interface FileSystemCreateWritableOptions {
|
||||
keepExistingData?: boolean
|
||||
}
|
||||
|
||||
interface FileSystemRemoveOptions {
|
||||
recursive?: boolean
|
||||
}
|
||||
|
||||
interface FileSystemFileHandle {
|
||||
getFile(): Promise<File>;
|
||||
|
||||
@@ -37,13 +41,16 @@ interface FileSystemWritableFileStream extends WritableStream {
|
||||
close(): Promise<undefined> // should be implemented in WritableStream
|
||||
}
|
||||
|
||||
|
||||
export declare interface FileSystemDirectoryHandle {
|
||||
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
|
||||
|
||||
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
|
||||
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FileSystemDirectoryHandle
|
||||
|
||||
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {fromByteArray as Base64Encode} from "base64-js";
|
||||
|
||||
export const IXAREA_API_ENDPOINT = "https://stats.ixarea.com/apis"
|
||||
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com"
|
||||
|
||||
export interface UpdateInfo {
|
||||
Found: boolean
|
||||
|
||||
@@ -144,9 +144,9 @@ export default {
|
||||
}
|
||||
try {
|
||||
this.dir = await window.showDirectoryPicker()
|
||||
window.dir = this.dir
|
||||
window.f = await this.dir.getFileHandle("write-test.txt", {create: true})
|
||||
|
||||
const test_filename = "__unlock_music_write_test.txt"
|
||||
await this.dir.getFileHandle(test_filename, {create: true})
|
||||
await this.dir.removeEntry(test_filename)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"types": [
|
||||
"webpack-env"
|
||||
"webpack-env",
|
||||
"jest"
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
@@ -26,7 +27,8 @@
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"scripthost"
|
||||
]
|
||||
],
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
|
||||
Reference in New Issue
Block a user